Разница между ранним и поздним связыванием
- пятница, 8 ноября 2024 г. в 00:00:04
В этой публикации я «на пальцах» попытаюсь объяснить, чем отличается раннее и позднее связывание кода для обычного программиста. Не для компилятора или статического анализатора, а для человека, который пишет 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 с минимальной конфигурцией, поддерживающий автозагрузку.
Если будут вопросы по использованию - с интересом отвечу.
Хэппи, как говорится, кодинг...