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

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

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

Комментарии (4)


  1. valeravv
    08.11.2024 03:48

    Статья что то типа картинки https://a.d-cd.net/87d8fb2s-480.jpg с заголовком "Сова и глобус" и пояснением "Глобус вы можете видеть в левом верхнем углу картинки". JS же интерпретируется, поиск методов класса/интерфейса/объекта осуществляется по человеко-читаемому имени на этапе run-time, для меня это позднее связывание однозначно. Трансляция TS->JS также ничего не меняет в этом плане, пока, надеюсь ... Или я пропустил, к tsc уже добавили флаг obfuscation по умолчанию?


    1. flancer Автор
      08.11.2024 03:48

      Вот то, что вы здесь написали, вы написали с точки зрения исполнителя кода. А то, что написано в статье - с точки зрения "писателя" кода:

      В этой публикации я «на пальцах» попытаюсь объяснить, чем отличается раннее и позднее связывание кода для обычного программиста. Не для компилятора или статического анализатора, а для человека, который пишет JavaScript/TypeScript-код.

      Попробуйте посмотреть на мой текст вот с этой точки зрения. И вы совершенно правы:

      поиск методов класса/интерфейса/объекта осуществляется по человеко-читаемому имени на этапе run-time

      и если

      для меня это позднее связывание однозначно

      то я могу за вас только порадоваться :)


      1. valeravv
        08.11.2024 03:48

        Если писать на JS то ничего никуда волшебным образом не пропадает, если писать на TS, то контекст, в который был помещен программист-писатель, будет утерян, да. При трансляции TS > JS (хотя в некоторых источниках это уже названо компиляцией). Потерю контекста (информации о типах и интерфейсах) можно пытаться назвать ранним связыванием, но в статье упоминаются шаблоны проектирования. Шаблоны проектирования опять же явным образом не описаны, а всего лишь подразумеваются и несть им числа, а значит опять в коде информация утеряна для его читателя (и на первый взгляд "позднее" связывание оказывается достаточно "раненьким").


        1. flancer Автор
          08.11.2024 03:48

          При чём тут трансляция/транспиляция? Когда вы, как программист, пишете код в котором вам нужно знать, где находятся исходники ваших зависимостей (import ... from ...) - это ранее связывание, когда вы предоставляете возможность кому-то из-вне дать вам эти зависимости - это позднее. Всё. Код после этого может транспилировать/компилироваться/интерпретироваться. Можно использовать любой ЯП. Инверсия контроля - она про позднее связывание. Но чтобы инверсия заработала, ваш код должен быть готов к этому. Вы сами, как разработчик, должны быть к этому готовы.

          Вот вам пример JS-кода с 6 зависимостями и без единого статического импорта. Чистейшее позднее связывание. На этапе написания кода вы декларируете, какого типа (с каким интерфейсом) объект (уже готовый и настроенный!) вы хотите получить в качестве зависимости, а что вы получите во время исполнения - зависит от приложения, в котором этот код работает.

          Спасибо за ваши комментарии, всегда интересно понять, как именно по-другому люди видят те же самые вещи и процессы.