В этой публикации я «на пальцах» попытаюсь объяснить, чем отличается раннее и позднее связывание кода для обычного программиста. Не для компилятора или статического анализатора, а для человека, который пишет JavaScript/TypeScript-код.
Для начала пара определений от «Игорь Иваныча» (ИИ), просто в качестве отправной точки:
Раннее связывание (early binding) — это процесс, при котором все связи между вызовами функций и их реализациями устанавливаются на этапе компиляции. В этом подходе компилятор заранее определяет, какой метод или функция будет вызвана, что обеспечивает высокую производительность и безопасность типов, так как ошибки могут быть обнаружены ещё до выполнения программы.
Позднее связывание (late binding) — это процесс, при котором конкретная реализация метода или функции определяется на этапе выполнения программы, а не на этапе компиляции.
Пример
А теперь — по-простому. Вот TypeScript-код, который использует раннее связывание:
class Cat { speak(): void { console.log("Meow"); } } function animalSound(cat: Cat): void { cat.speak(); } const myCat = new Cat(); animalSound(myCat);
А это — аналогичный TypeScript-код, который использует позднее связывание:
interface Animal { speak(): void; } class Cat implements Animal { speak(): void { console.log("Meow"); } } function animalSound(animal: Animal): void { animal.speak(); } const myCat = new Cat(); animalSound(myCat);
Вот в этом interface Animal
и заключается вся разница.
Видно, что кода стало больше, но что нам это дало? А дало возможность декомпозиции нашего кода на составные части:
// animal.ts export interface Animal { speak(): void; } export function animalSound(animal: Animal): void { animal.speak(); }
// cat.ts import {Animal} from './animal'; export class Cat implements Animal { speak(): void { console.log("Meow"); } }
// main.ts import {animalSound} from './animal'; import {Cat} from './cat'; const myCat = new Cat(); animalSound(myCat);
У нас получилась такая цепочка зависимостей:
animal.ts => cat.ts => main.ts
Если же мы попытаемся разбить «ранне-связанный» код, то у нас получится немного другая цепочка зависимостей:
cat.ts => animal.ts => main.ts
// cat.ts export class Cat { speak(): void { console.log("Meow"); } }
// animal.ts import {Cat} from "./cat"; export function animalSound(animal: Cat): void { animal.speak(); }
// main.ts import {animalSound} from './animal' import {Cat} from './cat' const myCat = new Cat(); animalSound(myCat);
Если мы захотим добавить dog
в приложение, то animal.ts
в коде с ранним связыванием примет вот такой вид:
// animal.ts import {Cat} from "./cat"; import {Dog} from "./dog"; export function animalSound(animal: Cat | Dog): void { animal.speak(); }
А вот в коде с поздним связыванием animal.ts
не изменится.
Вообще.
Вне зависимости от того, сколько и каких животных в каком проекте нужно будет добавлять.
Инверсия Контроля
То есть, при позднем связывании разработчик «думает» не в категориях классов, которые он поставляет «наружу«, а в категориях интерфейсов, которые он получает «извне» или отдаёт туда же. Он либо сам определяет интерфейсы (требования к будущим потребителям его кода), либо отталкивается от интерфейсов, уже определённых внешним потребителем его кода.
Это несколько контр-интуитивно для тех, кто начинает изучать ООП с «Hello World!» и продолжает двигаться вперёд применяя только инкапсуляцию и наследование. Но как только впервые появляется потребность в полиморфизме, появляется возможность посмотреть на свой код с точки зрения уже позднего связывания.
На самом деле позднее связывание усложняет построение простых приложений и облегчает построение приложений сложных. Если вам повезло и вы сразу начали изучать программирование с построения сложных приложений, то, скорее всего, для вас перверсией инверсией является как раз раннее связывание.
Контейнер Объектов
В случае раннего связывания наш код все зависимости тянет через статические импорты:
import {Cat} from './cat';
Тут всё понятно — и сами разработчики, и куча инструментов (IDE, транспиляторы, анализаторы, …) умеют в статические импорты.
А как же подтягиваются зависимости в случае позднего связывания? Ведь на момент написания кода мы знаем только интерфейсы зависимостей («ходит как утка» и вот этот вот всё)? Кто на момент выполнения кода определяет, какой исходный код, имплементирующий соответствующий интерфейс, должен быть загружен и выполнен, чтобы получить нужную зависимость?
В классическом «кровавом энтерпрайзе» (Java, C#) уже давно ответили на этот вопрос — в приложении должен быть объект, который знает как, когда и какие объекты создавать и когда и куда их внедрять. Обычно его называют «контейнер объектов«.
Так вот, контейнер объектов внедряет в качестве зависимостей не классы, а готовые объекты с заявленным интерфейсом — синглтоны или экземпляры, по ситуации.
Вместо создания из классов нужных экземпляров по месту их использования:
import {animalSound} from './animal'; import {Cat} from './cat'; const cat = new Cat(); export class CatSound { makeSound() { animalSound(cat); } }
Вы даёте возможность контейнеру объектов предоставить в ваш код нужные зависимости. Например, через конструктор (пример ниже — это уже JavaScript):
export class CatSound { /** * @param {Cat} cat * @param {function(animal: Cat): void} animalSound */ constructor(cat, animalSound) { this.makeSound = function () { animalSound(cat); }; } }
Ваш код сразу же работает с инициализированными объектами. Вам не нужно думать, это одиночки или отдельные экземпляры. Реальные это объекты или моки. Вы просто пишете код, который взаимодействует с объектами с заявленным интерфейсом. Вам не нужны статические импорты, ведь ваш код ориентирован на позднее связывание, а в runtime их просто не будет.
Я в TypeScript не силён, этот код, аналогичный предыдущему, мы писали с «Игорь Иванычем«:
import {Cat} from './cat'; export class CatSound { constructor(private cat: Cat, private animalSound: (animal: Cat) => void) { } public makeSound(): void { this.animalSound(this.cat); } }
Так вот, после компиляции в JavaScript статические импорты исчезли, как ненужные:
export class CatSound { constructor(cat, animalSound) { this.cat = cat; this.animalSound = animalSound; } makeSound() { this.animalSound(this.cat); } }
Что и ожидаемо — ведь наш код ориентирован на позднее связывание, на runtime.
Резюме
На мой взгляд, разницу между типами связывания для программиста, вульгарно, можно свести к следующему:
-
раннее: работаем с классами и создаём объекты сами.
-
позднее: работаем с контрактами (интерфейсами) и используем готовые объекты, которые предоставляет нам контейнер.
КДПВ как раз демонстрирует идею раннего связывания — вы строите своё приложение из исходников и сами создаёте нужные вам объекты.
Конечно же это очень простое и очень субъективное объяснение. Тем не мнее, возможно, кому-то даст возможность посмотреть на знакомые вещи под незнакомым углом.
Для тех, кто никогда не пробовал позднего связывания, но очень хочет попробовать, вот несколько библиотек, поддерживающих внедрение зависимостей:
-
InversifyJS — Мощный DI-контейнер для TypeScript и JavaScript с поддержкой декораторов и аннотаций типов.
-
Awilix — Гибкий и лёгкий DI-контейнер для Node.js, оптимизированный для Express и модульных приложений.
-
BottleJS — Минималистичный DI-контейнер для JavaScript, поддерживающий фабрики и сервисы.
Ну и по традиции — немного саморекламы. Подписывайтесь на мой телеграм-канал попробуйте мою библиотеку!!
-
teqfw/di — DI-контейнер для модульной разработки на JavaScript с минимальной конфигурцией, поддерживающий автозагрузку.
Если будут вопросы по использованию — с интересом отвечу.
Хэппи, как говорится, кодинг…
ссылка на оригинал статьи https://habr.com/ru/articles/856578/
Добавить комментарий