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

    Часть 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<ArgType>
    {
        (arg: ArgType, context?: any);
    }
    


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

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

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


    Добавим следующий интерфейс:

    export interface ICallbackDesc<ArgType>
    {
        Callback: ICallback<ArgType>;
        Subscriber: any;
    }
    


    ICallbackDesc нам понадобится для хранения информации о контексте, в котором должен быть вызван callback. Т.е. мы должны запомнить, какое значение должен получить this в callback'е, иначе мы просто не сможем работать полноценно.

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

    export class Event<ArgType>
    {
        private Callbacks: ICallbackDesc<ArgType>[] = [];
    
        /** Подписаться на событие
        * @param {ICallback<ArgType>} callback Callback, который будет вызван при вызове функции
        * @param {any} subscriber Контекст, в котором должен быть вызван callback
        * @returns {ITypedSubscription<ArgType>} Объект типизированной подписки
        */
        public Subscribe(callback: ICallback<ArgType>, subscriber: any): ITypedSubscription<ArgType>
        { 
            var that = this;
    
            var res: ITypedSubscription<ArgType> =
            {
                Callback: callback,
                Event: that,
                Unsubscribe: function () { that.Unsubscribe(callback); }
            }
    
            this.Callbacks.push({ Callback: callback, Subscriber: subscriber });
    
            return res;
        }
    
        public Unsubscribe(callback: ICallback<ArgType>): void
        { 
            //Делаем работу
        }
    
        public Trigger(arg: ArgType, context?: any): void 
        {
            //Делаем работу
        }
    }
    


    На выходе получаем, что теперь Callback (переименованный в Delegate) всегда реализует интерфейс ICallback.

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

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


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

    export class MessagesRepo
    {
        public MessagesLoaded = new Events.Event<string[]>();
    
        public ErrorHappened = new Events.Event<ErrorHappenedOptions>();
    }
    


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

    public Subscribe()
    {
        //Одним движение регистрируем подписку одного события
        this.Subscriptions.push(MessagesRepoInstance.MessagesLoaded.Subscribe(function (messages: string[], context?: any)
        {
            alert(messages && messages.length > 0 ? messages[0] : 'Nothing');
        }, this));
    
        //И совершенно другого
        this.Subscriptions.push(MessagesRepoInstance.ErrorHappened.Subscribe(function (error: ErrorHappenedOptions) 
        {
            alert(error.Error);
        }, this));
    }
    


    //Тут задаем context при вызове
    subscriber.MessagesRepo.MessagesLoaded.Trigger(['Test message 1'], this);
    //А тут нет
    subscriber.MessagesRepo.ErrorHappened.Trigger({ Error: 'Test error 1' });
    


    Замечание по реализации Unsubscribe


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

    public Unsubscribe(callback: ICallback<ArgType>): void 
    { 
        var filteredList: ICallbackDesc<ArgType>[] = [];
    
        for (var i = 0; i < this.Callbacks.length; i++)
        {
            if (this.Callbacks[i].Callback !== callback)
            {
                filteredList.push(this.Callbacks[i]);                
            }
        }
    
        this.Callbacks = filteredList;
    }
    


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

    Почему так происходит? При создании нового экземпляра объекта создаются и экземпляры его членов. Если вспомнить, что в 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'.

    Вызов события


    Не забудем задать правильный контекст при помощи apply:

    public Trigger: ICallback<ArgType> = function (arg: ArgType, context?: any)
    {
        var callbacks: ICallbackDesc<ArgType>[] = this.Callbacks;
        for (var i = 0; i < callbacks.length; i++)
        {
            callbacks[i].Callback.apply(callbacks[i].Subscriber, [arg, context]);
        }
    }
    


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

    Upd. Посто обновлен с учетом критики. Всем огромное спасибо за высказанные комментарии.
    Метки:
    Поделиться публикацией
    Комментарии 10
    • +1
      т.к. все объекты это функции, а функции объекты

      ({})() // TypeError: object is not a function
      

      ЧЯДНТ?
      • 0
        Ваша правда. К исправлению. Проглядел на ночь глядя.
      • +1
        Спасибо за статью, занятно. Выскажу ИМХО:

        Строгая типизация это, конечно, замечательно, но явная в языке, транслируемом в js… От подобного:
        public MessagesLoaded: Events.Event<Events.ICallback<string[]>, string[]>
            = new Events.Event<Events.ICallback<string[]>, string[]>();
        
        у меня волосы становятся дыбом.

        Ради автодополнения городить такое слишком, когда без явной типизации аналогичный код укладывается в 8 строк:
        class Event
         -> @Callbacks = []
         Add : ->
          Callback: @Callbacks[*] = it
          Event: @
          Unsubscribe: @Remove.bind @,it
         Remove : !(callback)-> @Callbacks .= filter (!=callback) 
         Trigger : !(options,context)-> for @Callbacks => ..apply context,options
        

        Лучше я буду писать в блокноте, но во много раз меньше :) В разы меньше кода — сложнее сделать ошибку. Конечный js аналогичен — скорость соответствует. Ну а строгую типизацию проще вбить в голову, чем писать, повторюсь, в разы больше.
        • 0
          С одной стороны тут ваша правда есть. Меня тоже временами от подобных конструкций передергивает. С другой, никак.

          Автодополнение не является целью. Оно прекрасно и без этого работает. Моя цель — статический анализ, и, как следствие, предотвращение регрессий при рефакторинге. Даже без специального ПО, контроль типов на стадии компиляции устраняет минимум половину типовых ошибок.

          Во-вторых, нет ничего страшного в том, чтобы один раз объявить событие. Вам же не нужно писать такой «ужас» повсеместно. Подписка и т.д. вполне в стиле JS.

          В любом случае, нет идеальных решений. Любой подход реализуется в контексте проекта. В моем это нужно. В куче других нет.
          • 0
            Автор увлекается аннотированием, на мой взгляд, излишне. В статье почти все (!) аннотации типов можно выкинуть, *ничего* не потеряв в типизиции. TS отлично умеет выводить типы, как по инициализаторам, так и по использованию.

            У в моей аналогичной библиотеке такой код выглядел бы так:
            public messagesLoaded = new Event<string[]>();

            При этом messagesLoaded имеет четкий тип Event<string[]> — аннотация тут просто не нужна.
          • +1
            я всё-таки так и не понял зачем нам 2 раза руками указывать параметр Options
            export class Event<Delegate extends ICallback<Options>, Options>
            чем мой вариант из прошлого поста хуже?
            • 0
              Честно говоря, как-то не внимательно прочитал ваш комментарий к прошлой статье и вообще упустил подобный вариант записи.

              Тем не менее, отличия есть.

              У вас было так:

              interface IEvent<Data> { on(fn:(Data) => boolean): ISubscribe { } trigger(d: Data): boolean { } }

              По сути, разницы никакой, за исключением того, что вы использовали «анонимный тип». fn:(Data) => boolean это не что иное, как вынесенный мной наружу ICallback записанный анонимно. Ваша запись короче и имеет полное право на жизнь, если мы гарантированно больше нигде не будем использовать этот тип callback'а. Если же нам надо, чтобы сигнатуры callback'ов разных событий были строго одинаковы по той или иной причине, то нам нужно их типизировать делегатом, иначе мы можем получить неявные последствия при рефакторинге в виде расхождений сигнатур, что при необязательных параметрах или параметрах типа any может иметь сложноуловимые последствия. Тут уже возникает синтаксическая избыточность или я просто не нашел способа более короткой записи.

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

              Идеальным вариантом может быть унаследовать мою реализацию от вашей и применять их по необходимости.
              • 0
                В общем, если все совместить, то получим:

                export interface ICallback<ArgType>
                {
                    (arg: ArgType, context?: any);
                }
                
                export interface ICallbackDesc<ArgType>
                {
                    Callback: ICallback<ArgType>;
                    Subscriber: any;
                }
                
                export class Event<ArgType>
                {
                    private Callbacks: ICallbackDesc<ArgType>[] = [];
                
                    public Subscribe(callback: ICallback<ArgType>, subscriber: any): ITypedSubscription<ArgType>
                    { 
                        //Выполняем работу
                    }
                
                    public Unsubscribe(callback: ICallback<ArgType>): void 
                    { 
                        //Выполняем работу
                    }
                
                    public Trigger: ICallback<ArgType> = function (arg: ArgType, context?: any)
                    {
                        //Выполняем работу
                    }
                }
                
                /** Базовый интерфейс подписки на событие. Минимальная функциональность. Можем просто отписаться и все. */
                export interface ISubscription
                {
                    Unsubscribe: { (): void };
                }
                
                /** Типизированная версия. Включает ссылки на событие и callback */
                export interface ITypedSubscription<ArgType> extends ISubscription
                {
                    Callback: ICallback<ArgType>;
                    Event: Event<ArgType>;
                }
                


                т.е., ровно то, что вы предлагали, но с вынесенным во вне делегатом.

                Я немного перестарался, как и писал в прошлом комментарии. Спасибо за подсказку.
                • 0
                  Да, я пришел к такому же результату.
                  Ждем продолжения цикла :)
                  • 0
                    Спасибо. Продолжение обязательно будет. Но пока мне надо завершить внутренний холивар на тему очередных велосипедов и их необходимости в реальной жизни :) Ну, и еще выспаться после рабочей недели)

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