Разница между ранним и поздним связыванием

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

Если будут вопросы по использованию — с интересом отвечу.

Хэппи, как говорится, кодинг…

© Habrahabr.ru