понедельник, 20 апреля 2009 г.

JavaScript: Контроль типов

JavaScript считается слабо-типизированным зыком не потому, что у него нету типов, а потому, что у переменных в этом языке не фиксируется, на объект или примитив какого типа они будут смотреть. При этом интерпретатор JS преобразует типы в зависимости от контекста их использования.

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

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

В интегрированной среде разработки Eclipse есть такое средство, как Snippets, прекрасно подходящее для решения задачи контроля типов в JavaScript. Snippet - это кусочек кода в общем виде, настраиваемый под конкретную ситуацию путём редактирования некоторых имён и значений, называемых переменными snippet`ов. Их можно создавать и группировать, что бы потом удобно было находить и использовать. Я у себя создал группу "Params check" и в ней расположил snippet`ы для всех примитивов.

Перед тем, как начать, следует ввести константы-обозначения типов, с которыми будет проще осуществлять эти проверки. Эти константы будут иметь строковые типы и будут служить для сравнения результатов операции typeof, применяемой к переменным:
var undefined,
    Types = {
        // Primitive types
        /** @type {string} */ STRING_TYPE: 'string',
        /** @type {string} */ OBJECT_TYPE: 'object',
        /** @type {string} */ NUMBER_TYPE: 'number',
        /** @type {string} */ BOOLEAN_TYPE: 'boolean',
        /** @type {string} */ FUNCTION_TYPE: 'function',
        /** @type {string} */ UNDEFINED_TYPE: 'undefined'
    };
Итак, начнём с самого простого типа - boolean. На данный момент я произвожу проверку на него следующим блоком кода:
//Param x as boolean check
if (typeof x !== Types.BOOLEAN_TYPE)
    if (x instanceof Boolean)
        x = x.valueOf();
    else {
        if (x instanceof String)
            x = x.valueOf();

        if (typeof x === Types.STRING_TYPE)
            x = x !== '' && x !== 'false';
        else
            x = x ? true : false;
    }
, изначальный же Snippet для этогой проверки должен содержать переменную snippet`а для имяни переменной, что бы внести его один раз и больше не маяться:
//Param ${varName} as boolean check
if (typeof ${varName} !== Types.BOOLEAN_TYPE)
    if (${varName} instanceof Boolean)
        ${varName} = ${varName}.valueOf();
    else {
        if (${varName} instanceof String)
            ${varName} = ${varName}.valueOf();

        if (typeof ${varName} === Types.STRING_TYPE)
            ${varName} = ${varName} !== '' && ${varName} !== 'false';
        else
            ${varName} = ${varName} ? true : false;
    }
Следующий пример - для наиболее частого в использовании типа - строк:
//Param s1 as string check
if (s1 === null || typeof s1 === Types.UNDEFINED_TYPE)
    throw new TypeError(
        'Required param - s1 - is not specified by function call!'
    );

if (typeof s1 !== Types.STRING_TYPE)
    s1 = s1 instanceof String ?
            s1.valueOf()
            : s1.toString();

if (!/^[1-0a-f]*$/i.test())
    throw SyntaxError('Required param - s1 - has invalid format.');
Обратите внимание, что здесь у нас уже две переменных для строки я счёл важным не только проверку её типа, но ещё и правильную проверку её формата с использованием регулярного выражения. В данном примере по шаблону проверяется, является ли строка представлением числа в 16-ричном формате.
Теперь решение в общем виде - string Snippet:
//Param ${varName} as string check
if (${varName} === null || typeof ${varName} === Types.UNDEFINED_TYPE)
    throw new TypeError(
        'Required param - ${varName} - is not specified by function call!'
    );

if (typeof ${varName} !== Types.STRING_TYPE)
    ${varName} = ${varName} instanceof String ?
            ${varName}.valueOf()
            : ${varName}.toString();

if (!/^${regexp}$$/.test())
    throw SyntaxError('Required param - ${varName} - has invalid format.');
Что же касается числового типа - number, то здесь имеется небольшой подводный камешек. Дело в том, что целые и вещественные числа спецификация ECMAScript не различает. Однако, как правило, в сценарии по логике легко понять, какого именно числа мы ждём - целого или дробного, так что я сделал два разных блока проверки - для целых (integer) и вещественных (float) чисел:
//Param x as integer check
if (x === null || typeof x === Types.UNDEFINED_TYPE)
    throw new TypeError(
        'Required param - x - is not specified by function call.'
    );

if (typeof x !== Types.NUMBER_TYPE)
    if (x instanceof Number)
        x = x.valueOf();
    else {
        if (x instanceof String)
            x = x.valueOf();

        if (typeof x === Types.STRING_TYPE)
            x = parseInt(x);
        else
            throw new TypeError(
                'Required param - x - has invalid type!'
            );
    }

if (x < 1 || x > 100)
    throw new RangeError(
        'Required param - x = '
        + x
        + ' - is out of correct range (1 - 100).'
    );


//Param y as float check
if (y === null || typeof y === Types.UNDEFINED_TYPE)
    throw new TypeError(
        'Required param - y - is not specified by function call.'
    );

if (typeof y !== Types.NUMBER_TYPE)
    if (y instanceof Number)
        y = y.valueOf();
    else {
        if (y instanceof String)
            y = y.valueOf();

        if (typeof y === Types.STRING_TYPE)
            y = parseFloat(y);
        else
            throw new TypeError(
                'Required param - y - has invalid type.'
            );
    }

if (y < 0.55 || y > 0.99)
    throw new RangeError(
        'Required param - y = '
        + y
        + ' - is out of correct range (0.55 - 0.99).'
    );
Видим, что для чисел кроме типа проверяется тот интервал значений, в который они должны попадать. Если они не входят в него, возбуждается исключение. Snippet`ы:
//Param ${varName} as integer check
if (${varName} === null || typeof ${varName} === Types.UNDEFINED_TYPE)
    throw new TypeError(
        'Required param - ${varName} - is not specified by function call.'
    );

if (typeof ${varName} !== Types.NUMBER_TYPE)
    if (${varName} instanceof Number)
        ${varName} = ${varName}.valueOf();
    else {
        if (${varName} instanceof String)
            ${varName} = ${varName}.valueOf();

        if (typeof ${varName} === Types.STRING_TYPE)
            ${varName} = parseInt(${varName});
        else
            throw new TypeError(
                'Required param - ${varName} - has invalid type!'
            );
    }

if (${varName} < ${min_value} || ${varName} > ${max_value})
    throw new RangeError(
        'Required param - ${varName} = '
        + ${varName}
        + ' - is out of correct range (${min_value} - ${max_value}).'
    );


//Param ${varName} as float check
if (${varName} === null || typeof ${varName} === Types.UNDEFINED_TYPE)
    throw new TypeError(
        'Required param - ${varName} - is not specified by function call.'
    );

if (typeof ${varName} !== Types.NUMBER_TYPE)
    if (${varName} instanceof Number)
        ${varName} = ${varName}.valueOf();
    else {
        if (${varName} instanceof String)
            ${varName} = ${varName}.valueOf();

        if (typeof ${varName} === Types.STRING_TYPE)
            ${varName} = parseFloat(${varName});
        else
            throw new TypeError(
                'Required param - ${varName} - has invalid type.'
            );
    }

if (${varName} < ${min_value} || ${varName} > ${max_value})
    throw new RangeError(
        'Required param - ${varName} = '
        + ${varName}
        + ' - is out of correct range (${min_value} - ${max_value}).'
    );
Вот и всё. Остаётся лишь добавить, что чаще всего я использую такие проверки для написания внешних библиотек, что бы контролировать те значения, которые приходят в мою функцию. Эти проверки излишни, если тот код, который вы пишете, будет вызываться так же вами и, таким образом, легче установить контроль с вызывающей стороны. Однако бывают ситуации, когда параметры вызова так или иначе зависит не от вас и тогда такие проверки бывают оправданны.

5 комментариев:

Unknown комментирует...

Интересный метод. Не проще ли обойтись простым сравнением конструктора поступившего параметра с необходимым типом? К примеру, ожидается строка:
if (srt && str.constructor == String) {;}

Unknown комментирует...

либо короче:
str = str.constructor == String && str;

C'est la vie комментирует...

Дело не столько в простоте, сколько в правильности. Просто нужно понимать, какие операции языка стоят за теми или иными его выражениями. За тем, что предлагаете вы, стоит следующее - JS-машина преобразует примитивное значение строки в объект String и затем вызывает у него метод constructor, сравнивая его (при чём, отметим, нечётко сравнивая, т.е. производя ещё кучу дополнительных условий!) с функцией String. Тогда как typeof не требует преобразования примитива в объект и по-этому работает, используя минимум ресурсов.
К сожалению, JavaScript даёт большие возможности по написанию не очень хороших сценариев с точки зрения оптимальности расходования ресурсов - и подавляющее большинство JS-программистов это используют, не задумываясь об этом. Мне в этом блоге хотелось бы обратить внимание на вопросы правильного написания JS-кода.

Unknown комментирует...

Окей, товарищ Java-программист, понял твою страсть всё имплементировать в объекты, даже, на мой неискушённый взгляд, такую простую вещь, как проверка ожидаемого типа )

С точки зрения производительности я как раз-таки и рассуждал, что простое обращение к конструктору переменной "легче" (пусть и с преобразованием примитива в объект), чем несколько вызовов функции typeof и свойств
объекта Types. Впрочем, это надо ещё проверять, что будет быстрее - это же JavaScript ("как хочу, так и реализовываю интерпретатор в своём браузере"), тесты производительности дают порой противоположные логике результаты даже в современных браузерах.

Согласен с замечанием, неявное сравнение == здесь плод поспешности, конечно сравнивать с приведением типов.

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

C'est la vie комментирует...

Вообще, в целях убыстрения, можно при компрессии (например, YUICompressor`ом) менять константы объекта Types на их строковые значения - мне кажется, это будет быстрее, чем вызывать поле объекта. Я сейчас как раз работаю над такого рода add-on`ами к YUICompressor`у, которые позволяли бы такое проделывать, ориентируясь на JSDoc-комментарии. Но это - довольно сложная задача...