Построение масштабируемых приложений на TypeScript — Часть 2.5. Работа над ошибками и делегаты

в 21:19, , рубрики: Events, generic, javascript, TypeScript, Веб-разработка, Программирование, метки: , , ,

Часть 1: Асинхронная загрузка модулей
Часть 2: События или зачем стоит изобретать собственный велосипед

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

Но просто написать работу над ошибками было бы не интересно. К счастью, процесс ее исправления сам по себе подкинул пару интересных находок и мыслей, которые я хотел бы вынести на суд сообщества.

Итак, мой WinAmp играет коллекцию хитов Ozzy Osbourne, а всех интересующихся прошу под кат.

Ошибка

Во второй части статьи, в разделе «Типизация параметров события» был опубликован следующий код (здесь полный код из Codeplex):

export class Event<Callback extends Function, Options>
{
    private Callbacks: Callback[] = [];

    public Add(callback: Callback): ITypedSubscription<Callback, Event<Callback, Options>>
    { 
        var that = this;

        var res: ITypedSubscription<Callback, Event<Callback, Options>> =
        {
            Callback: callback,
            Event: that,
            Unsubscribe: function () { that.Remove(callback); }
        }

        this.Callbacks.push(callback);

        return res;
    }

    public Remove(callback: Callback): void 
    { 
        var filteredList: Callback[] = [];

        for (var i = 0; i < this.Callbacks.length; i++)
        {
            //Здесь тонкий момент. Комментарий ниже по статье
            if (this.Callbacks[i] !== callback)
            {
                filteredList.push(callback);
            }
        }

        this.Callbacks = filteredList;
    }

    public Trigger(options: Options): void 
    {
        for (var i = 0; i < this.Callbacks.length; i++)
        {
            this.Callbacks[i](options);
        }
    }
}

Ошибка состояла в том, что в сигнатуре класса Event:

Event<Callback extends Function, Options>

полностью отсутствует связь между ее обобщенными параметрами на уровне типов. Фактически, получается, что первый аргумент-тип Callback это некоторая произвольная функция, не имеющая определенной сигнатуры, а второй аргумент-тип Options это некоторый тип объекта, который мы потом используем для вызова callback'ов, добавленных в событие. Т.е. параметры не связаны между собой и в данной реализации элементарно отстрелить себе ногу не согласовав эти параметры, что неопытный разработчик скорее всего и не сделает.

И тут начинается уличная магия.

Делегаты

Русская Википедия говорит нам, что:

Делегат (англ. delegate) — структура данных, указывающая на методы (статические или экземпляра класса) в .NET Framework. Делегаты используются, в частности, для определения прототипа функции обратного вызова, например, в событийной модели .NET Framework.

Английская версия дополнительно указывает, что делегаты это type-safe function pointer, т.е. строго типизированная ссылка на функцию (перевод и трактовка вольные).

В зависимости от контекста, под делегатом может пониматься как тип, так и конкретный экземпляр.

Простыми словами, ближе к миру JS, это строго типизированная переменная, которая хранит функцию строго определенного типа. Иными словами делегат строго определяет сигнатуру метода.

В JS делегатов в явном виде не существует, т.к. все объекты это функции, а функции объекты, да еще и динамическая типизация. Это означает, что JS поддерживает делегаты без каких-либо дополнительных телодвижений.

Примером делегата в TS будет параметр callback из следующего примера:

function Boo(callback: (strings: string[]) => string) { /* Реализация */ }

Здесь мы строго типизируем параметр callback как метод, принимающий строго один обязательный параметр типа массива строк.

Но нам такая запись не подходит, т.к. нам нужен обобщенный делегат, который мы могли бы типизировать исходя из объявления события.

Во-первых мы должны объявить делегат как тип, чтобы использовать его повторно. К моему огромному удивлению, спецификация TS 0.9 и всемогущий Google ничем не смогли мне помочь в определении синтаксиса такой структуры. Ответ нашелся на Codeplex в обсуждениях по TS где-то на второй час поисков:

export interface IDelegateTest
{
    (options: any);
}

var test: IDelegateTest = function (options: any) { }

Этой записью мы объявляем, что интерфейс IDelegateTest это тип делегата, имеющего одни параметр типа any. При этом допускается изменение имени параметров, но не их количества и типа. Например, следующий код полностью корректен:

//Переименование
var test: IDelegateTest = function (settings: any) { }

Как и этот:

//Необязательный параметр
var test: IDelegateTest = function (options?: any) { }

И даже этот:

//Если можно необязательный параметр, то можно и опустить
var test: IDelegateTest = function () { }

Или этот:

//Проходит компиляцию!!!
var test: IDelegateTest = function (options: number) { }

«Стоп!» — скажет внимательный читатель. Мы меняем то, что не должны. Ответ: options объявлены как any, который все прощает. Честно говоря, я потратил минут 10, пока понял причину «в трех соснах». Надеюсь, что больше никто в эту ловушку не попадется.

А вот так все корректно:

export interface IDelegateTest
{
    //Зададим строгий тип
    (options: string);
}

// Cannot convert '(options: number) => void' to 'IDelegateTest'
var test1: IDelegateTest = function (options: number) { }

//Call signatures of types '(options?: number) => void' and 'IDelegateTest' are incompatible.
var test2: IDelegateTest = function (options?: number) { }

//А так можно
var test3: IDelegateTest = function (settings: string) { }

Во-вторых, переделаем нашего делегата в обобщенного и дадим ему правильное имя. Тут все ожидаемо с точки зрения синтаксиса и просто:

export interface ICallback<Options>
{
    (options: Options, context?: any);
}

Параметр options это наш типизированный параметр, а context позволяет нам задать объект, который вызвал событие, что может быть полезно, если событие вызывается неким третьим объектом и это надо отслеживать. Обращу внимание, что context никак не управляется из подписчика, в отличии от того же Backbone.Events, в котором контекст задается подписчиком для разделения однотипных обработчиков.

В этом коде есть один неявный момент. Если объявить context первым, согласно традициям C#: (object sender, EventArgs args), то компилятор TS не сможет отследить соответствие типов функций. Не знаю, что это: баг или фича, но на это следует обратить внимание. Если типизированные параметры идут в начале, то все работает предсказуемо. Что меня полностью устраивает в контексте статьи.

Исправленный Event

Скорректируем наш класс Event:

export class Event<Delegate extends ICallback<Options>, Options>
{
    private Callbacks: Delegate[] = [];

    public Add(callback: Delegate): ITypedSubscription<Delegate, Event<Delegate, Options>>
    { 
        var that = this;

        var res: ITypedSubscription<Delegate, Event<Delegate, Options>> =
        {
            Callback: callback,
            Event: that,
            Unsubscribe: function () { that.Remove(callback); }
        }

        this.Callbacks.push(callback);

        return res;
    }

    public Remove(callback: Delegate): void 
    { 
        //Делаем работу
    }

    public Trigger(options: Options, context?: any): void 
    {
        //Делаем работу
    }
}

На выходе получаем, что теперь Callback (переименованный в Delegate) всегда реализует интерфейс ICallback<Options> и типизируется связанным аргументом-типом Options, т.е. нет никакой возможности нарушить связь между ними.

Для полного типизированного счастья типизируем метод Trigger:

public Trigger: ICallback<Options> = function (options: Options, context?: any)
{
    //Делаем работу
}

После все преобразований, объявление события будет выглядеть так:

public MessagesLoaded: Events.Event<Events.ICallback<string[]>, string[]>
    = new Events.Event<Events.ICallback<string[]>, string[]>();

Подписка и вызов принципиально не изменятся:

public Foo()
{
    //Тут используем необязательный context
    this.Subscriptions.push(this.MessagesRepo.MessagesLoaded.Add(function (messages: string[], context?: any)
    {
        alert(messages && messages.length > 0 ? messages[0] : 'Nothing');
    }));

    //А тут нет
    this.Subscriptions.push(this.MessagesRepo.ErrorHappened.Add(function (error: ErrorHappenedOptions) 
    {
        alert(error.Error);
    }));
}
//Тут задаем context при вызове
subscriber.MessagesRepo.MessagesLoaded.Trigger(['Test message 1'], this);
//А тут нет
subscriber.MessagesRepo.ErrorHappened.Trigger({ Error: 'Test error 1' });
Замечание по реализации Remove

Рассмотрим следующий код:

public Remove(callback: Delegate): void 
{ 
    var filteredList: Delegate[] = [];

    for (var i = 0; i < this.Callbacks.length; i++)
    {
        //Смотрим на !==
        if (this.Callbacks[i] !== callback)
        {
            filteredList.push(callback);
        }
    }

    this.Callbacks = filteredList;
}

Главный нюанс заключается в операторе !==. Здесь я его применяю для сравнения объектов. Основа принципа работы строгого (не)равенства — сравнение типов, затем сравнение значений. Для нас это означает, что при многократной подписке на событие одной и той же функцией, метод Remove удалит все ссылки на данную функцию. Т.е. для подписки некорректно использовать статические члены классов и т.п., что, надеюсь, никто делать и не собирался. Для членов инстанцированных объектов одного типа все будет отлично.

Почему так происходит? При создании нового экземпляра объекта создаются и экземпляры его членов. Если вспомнить, что в JS объект и тип суть одно и тоже, то получается, что при каждом инстанцировании класса TS, мы создаем набор новых типов. Из-за этого сравнение типов полностью идентичных функций разных экземпляров приведет к их неравенству.

Т.е. следующий код будет выполнен полностю предсказуемо:

public static Main(): void
{
    var subscriber1: Messages.SomeEventSubscriber = new Messages.SomeEventSubscriber();
    var subscriber2: Messages.SomeEventSubscriber = new Messages.SomeEventSubscriber();

    subscriber1.Subscribe();
    subscriber2.Subscribe();

    Messages.MessagesRepoInstance.MessagesLoaded.Trigger(['Test message 1'], this);
    Messages.MessagesRepoInstance.ErrorHappened.Trigger({ Error: 'Test error 1' });

    subscriber1.Destroy();

    Messages.MessagesRepoInstance.MessagesLoaded.Trigger(['Test message 2'], this);
    Messages.MessagesRepoInstance.ErrorHappened.Trigger({ Error: 'Test error 2' });
}

Будет по 2 алерта 'Test message 1' и 'Test error 1', но только по одному алерту 'Test message 2' и 'Test error 2'.

На этом я завершаю с исправлением ошибок. Надеюсь на конструктивные комментарии и критику.

Автор: Keeperovod

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js