В процессе описания очередного набора тестов для модуля Node.js поймал себя на мысли "опять проверка типов". Каждый параметр метода класса, каждое свойство устанавливаемое с помощью сеттера надо проверять. Можно конечно просто забить или дополнять все кодом реализующим проверки или попробовать описать все декораторами. Но в этот раз поступим немного иначе.


Немного эмоций и сладости




Приход спецификации ES6 и реализации ее в различных движка дарит нам много удивительных вещей, по-моему глупо стоять в стороне от этого праздника. Вооружившись желанием поглубже погрузиться в новую спецификацию и немного упростить себе жизнь, попробуем реализовать анализ типов с помощью такой замечательной штуки как Proxy. Конечно мы будем использовать и другие плюшки ES6, нативно поддерживаемые Node.js версии 6.1.0, такие как: классы, map'ы, стрелочные функции и др.


Как этим пользоваться




  1. Устанавливаем пакет npm i typedproxy. Подключаем модуль


    //пункт 1.
    const Typed = require('typedproxy');

  2. Создаем класс, используем статические методы, методы, статические свойства и свойства. При описании параметров методов и сеттеров используем специальный синтаксис.


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


  4. Подробнее о объекте типов. В данном объекте должны быть перечислены все типы которые используются, или планируются к использованию в вашем классе. Если вы забыли выполнить указанное условие — это приведет к RangeError в процессе выполнения.
    И так немного кода для понимания принципов:


    //пункт 2. Создаем класс, описывая только конструктор.
    class TestClass {
    //пункт 3. Конструктор должен принимать параметр с типом myRange. 
    constructor(myRangeValue){
        this.value = myRangeValue;
    }
    };
    //пункт 4. Создаем объект типов. Здесь мы видим используемый ранее myRange.
    const types = {
    'myRange' : (value) => {
        if(value < 0 || value > 10) {
            throw new TypeError(`parameter must be more than 0 and less than 10, not ${value}`);
       }
    }
    }; 

    Как мы видим myRangeValue — это имя параметра, проверка которого определяется в свойстве объекта типов с соответствующим именем myRange.


  5. Теперь, что бы включить проверку типов необходимо сделать класс типизированным (это понятие конечно используется в рамках используемого модуля, не стоит здесь притягивать понятия из спецификации). А делаем мы это так, имея ранее описанные класс TestClass и типы:


    //пункт 5.
    const TypedTestClass = new Typed(TestClass, types);

  6. Выше мы получили новый класс TypedTestClass, который на самом деле является экземпляром Proxy, но об этом после. Его мы используем вместо TestClass, то есть создаем экземпляры, вызываем статические методы, как самого класса, так и его экземпляром. Вообщем делая все то, что хотели сделать с первоначальным классом TestClass.


    //пункт 6. Создадим несколько экземпляров класса.
    /*ok - параметр конструктора проходит проверку*/
    const instance1 = new TypedTestClass(5);
    /*TypeError - параметр конструктора не прошел проверку*/
    const instance2 =  new TypedTestClass(11);
    /*RangeError - количество параметров ожидаемых конструктором не соответствует
    количеству переданных в него параметров*/
    const instance3 =  new TypedTestClass();
    /*RangeError - количество параметров ожидаемых конструктором не соответствует 
    количеству переданных в него параметров*/
    const instance3 =  new TypedTestClass(1, 2);

    Как можно заметить передача неверного типа параметра теперь вызывает ошибку. Передача неверного количества параметров (не важно удовлетворяют ли они требованиям типа или нет) также вызывает ошибку.


  7. Замечание по использованию:
    7.1. Если вы используете наследование (например через extends) типизировать нужно конечный класс, а не всю цепочку. Ну во-первых зачем лишние переменные и лишний труд, а во-вторых у нас просто ничего не выйдет.
    7.2. Если вы используете параметры по умолчанию, то данный модуль пока вам не подходит (мы работает над этим).

Все вместе
//пункт 1.
const Typed = require('typedproxy');
//пункт 2. Создаем класс, описывая только конструктор.
class TestClass {
    //пункт 3. Конструктор должен принимать параметр с типом myRange. 
    constructor(myRangeValue){
        this.value = myRangeValue;
    }
};
//пункт 4. Создаем объект типов. Здесь мы видим используемый ранее myRange.
const types = {
    'myRange' : (value) => {
        if(value < 0 || value > 10) {
            throw new TypeError(`parameter must be more than 0 and less than 10, not ${value}`);
       }
    }
}; 
//пункт 5.
const TypedTestClass = new Typed(TestClass, types);
//пункт 6. Создадим несколько экземпляров класса.
/*ok - параметр конструктора проходит проверку*/
const instance1 = new TypedTestClass(5);
/*TypeError - параметр конструктора не прошел проверку*/
const instance2 =  new TypedTestClass(11);
/*RangeError - количество параметров ожидаемых конструктором не соответствует
 количеству переданных в него параметров*/
const instance3 =  new TypedTestClass();
/*RangeError - количество параметров ожидаемых конструктором не соответствует 
количеству переданных в него параметров*/
const instance3 =  new TypedTestClass(1, 2);

Как это работает




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


Взаимосвязь имен параметров и объекта описывающего типы:


relation


Как видно на рисунке и как было сказано выше необходимо установить прямую взаимосвязь между именами параметров методов и типами используемыми в нашем классе. То есть имя параметра, должно начинаться с последовательности символов соответствующих типу.
Это необходимо что бы заработала функция производящая проверку типа (о ней скажем немного позже). В принципе, если реализация данной функции вас не устраивает, можете передать свою третьим параметром, при создании типизированного класса.


const Typed = require('typedproxy');
class TestClass { 
   //описание класса...
};
const types = {
   //описание типов...
};
const TypedTestClass = Typed(TestClass, types, (types, someFunction, ...args) => {/*реализация функции проверки типов*/});

Принципиальная схема работы


workingscheme


При типизации класса создается и возвращается новый Proxy. Этот самый прокси и является классом осуществляющим анализ типов. Его суть состоит в применении функции проверки типов и определении необходимых ловушек для перехвата вызова статических методов, создания новых экземпляров и т.д.


Функция проверки типов (зелено-красные квадраты на рисунке) работает следующим образом:


  1. Получает список используемых типов, функцию и переданные в функцию параметры.
  2. Извлекает (при помощи регулярного выражения) имена параметров которые ожидает принять функция.
  3. Проверяет одинаково ли количество переданных и ожидаемых к принятию параметров. Если различно выкидывает исключение.
  4. Проверяет начинается ли имя ожидаемого параметра с последовательности символов соответствующих какому-либо из типов. Если не совпадает ни с одним выкидывает исключение.
  5. Исполняет функцию соответствующего типа.

Новых прокси содержит только три ловушки: get, set и construct.


  1. get — при доступе к статическому методу будет возвращать прокси который будет выполнять проверку типа, а лишь затем осуществлять перенаправление к статическому методу исходного класса.
  2. set — при попытке изменить значение статического свойства класса, при наличии сеттера, будет выполнять проверку типа, а лишь затем осуществлять установку указанного значения.
  3. construct — при вызове типизированного класса с оператором new производит проверку типов параметров передающихся в конструктор. После этого создает экземпляр первоначального класса и прокси на его основе (с двумя ловушками get и set, которые работаю похожими с указанными выше способами 1 и 2).

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

Поделиться с друзьями
-->

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


  1. rumkin
    18.05.2016 16:17

    Интересный пример использования Proxy. А как обстоят дела с instanceof и несколькими параметрами типа myRange в одном методе?
    Можно еще перегруженные методы добавить.


    1. antonecma
      18.05.2016 17:03

      1. instanceof для типизированного класса: Новый типизированный класс является объектом Object и соответственно Function.
      2. instanceof для объекта типизированного класса скажет нам, что и типизированный класс и нетипизированный класс есть в цепочке прототипов. Другими словами:
        console.log(typedInstance instanceof TestClass);// true
        console.log(typedInstance instanceof TypedMyClass);// true

        так получается, потому что ловушка getPrototypeOf не определена. Я это сделал умышлено — для возможности определения типизирован ли объект или нет.


        Несколько параметров в одном методе все хорошо. используйте сколько угодно параметров. Так делать можно и нужно:
        method(myRangeValueFirst, myRangeValueFirst)

        Можно еще перегруженные методы добавить.
        Точно — это идея мне не пришла в голову. Она достаточно тривиально реализуется. PR are welcome, как говорится).


    1. antonecma
      18.05.2016 17:18

      Черт ошибочка небольшая.
      Несколько параметров в одном методе все хорошо. Используйте сколько угодно параметров. Так делать можно и нужно:


      method(myRangeValueFirst, myRangeValueSecond)


  1. bromzh
    18.05.2016 16:31

    А почему бы не использовать Typescript, декораторы и reflect-metadata (например, как тут, см. в конце)?


    1. antonecma
      18.05.2016 17:08

      Да можно и Typescript использовать для похожих целей. Только, что делать если ты уже написал класс на чистом javascript?


      1. bromzh
        18.05.2016 18:18

        Достаточно декларировать этот класс в .d.ts-файле и подключить этот файл к компиляции. А в целом, ts является надмножеством js, так что валидный js-код будет валидным ts-кодом.
        Если, например, класс написан в "es6-стиле", то можно просто изменить расширение файла на .ts. А чтобы компилятор не ругался на отсутствующие поля, достаточно просто их перечислить. Т.е.


        // foo.js
        export class Foo {
            constructor() {
                this.foo = 1;
            }
        }
        
        // foo.ts
        export class Foo {
            foo; // указываем, что есть такое поле. если не указать явно, тип будет any.
        
            constructor() {
                this.foo = 1;
            }
        }

        Если класс написан в "es5-стиле", то достаточно изменить расширение на .ts и продекларировать тип:


        // foo.js -> foo.ts
        var Foo = (function () {
            function Foo() {
                this.foo = 1;
            }
            Foo.prototype.sum = function (x) {
                return this.foo + x;
            };
            return Foo;
        }());
        
        module.exports = Foo;
        
        // foo.d.ts
        declare class Foo {
            foo: any;
            sum(x: number): number;
        }


        1. antonecma
          18.05.2016 22:44

          Прочел про декораторы в TypedScript и действительно можно очень классно и быстро реализовать так называемую типизацию. Эта штука выглядит даже немного приятнее. Кстати я и в начале статьи написал, что можно обойтись декораторами(в понятиях ES5). Все ведь знают, что очень часто существует более чем один способ решить задачу. Это всего лишь мой взгляд на ситуацию.


    1. vintage
      19.05.2016 08:34

      Эта штука ещё слишком сырая:


          @xxx
          yyy( a: Foo[] ){
              return a
          }

          Foo.prototype.yyy = function (a) {
              return a;
          };
          __decorate([
              xxx, 
              __metadata('design:type', Function), 
              __metadata('design:paramtypes', [Array]), 
              __metadata('design:returntype', void 0)
          ], Foo.prototype, "yyy", null);


  1. Alternator
    18.05.2016 22:21

    Есть и другие инструменты проверки типов в рантайме(а, также на этапе трансляции/сборки).
    Из известного мне — github.com/codemix/babel-plugin-typecheck.

    Это плагин к babel, который автоматически добавляет проверки на тип, если вы описали тип.
    Для описания типов используется flow type (http://flowtype.org/) синтаксис.

    плюсы:
    + возможность проверять не только входящие аргументы, но и локальные переменные.
    + не нужна принудительная верблюжья нотация для описания типов — для описания типов используется отдельный синтаксис.
    + не нужно как-то особо описывать используемые вами классы
    плагин самостоятельно сгенерит typeof/instanceof-проверки, если вы задали настоящий JS-тип для переменной
    + скорость в рантайме. Не приходится использовать Proxy, а сами проверки встраиваются инлайново, а не вызовами функций.
    + оттранслированный код работает в любом окружении, а не только там, где есть Proxy.
    минусы:
    — не всем может понравится синтасис описания типов в коде.
    Это уже не чистый JS, хотя и поддерживаемый некоторыми IDE


    1. webschik
      19.05.2016 15:06

      TypeScript?


      1. Alternator
        20.05.2016 02:09

        Да нет же, это две независимых вещи
        typescript, насколько я понимаю язык, в котором синтаксис типов является хоть и центральной, но далеко не единственной синтаксической фичей
        flowtype же, это инструмент статического анализа, который позволяет проверять типы, и на этом все
        При этом я полагаю что делает он эту задачу лучше(во всяком случае в первом примере для flowtype, typescript не видит никаких проблем)

        И да, к слову, flowtype все-таки позволяет описывать типы в комментариях.
        То есть вы все-таки можете использовать типы, и при этом писать «чистый» JS.
        Правда сам синтаксис остается принципиально тем же(просто экранирован комментариями), так что в этом мало смысла, если вы используете IDE, знающую о flowtype — поэтому я не вижу смысла в этой «чистоте».
        Но это скорее дело личного вкуса.