Сага о типизации и тайпчекинге для JavaScript
Привет! Хочу поделиться своими мыслями по, казалось бы, простой теме — типизации. В частности, поговорить о тайпчекинге в JavaScript.
Часто люди воспринимают типизацию как эдакую серебряную пулю, которая защищает от всех проблем. Но это не так, часто ожидания от типизации неоправданны, а простота таких инструментов, как TypeScript, обманчива и слишком переоценена.
Это не типичная статья из серии «Изучаем TypeScript», а, как мне кажется, фундаментальная история. Мы начнем с основ, поговорим о данных, о способе их хранения, а затем перейдем к типизации и посмотрим, как она влияет на работу программы.
Вопросы, над которыми я предлагаю задуматься после прочтения статьи:
Что такое типизация?
Чего я ожидаю от типизации?
Оправданны ли эти ожидания?
Основы
Как известно, переменные нужны для хранения данных. Это могут быть строки, числа, булевы значения или разного рода структуры. Когда мы создаем переменную, то присваиваем ей имя и значение (данные). Данные должны где-то физически храниться. Таким хранилищем выступает оперативная память (ОЗУ). Каждый раз, когда мы записываем что-то в переменную, мы фактически модифицируем содержимое ОЗУ. Другой вопрос — как значения переменных хранятся в памяти и сколько места занимают. Посмотрим на пример из языка C/С++:
int foo = 1;
cout << sizeof foo << endl; // 4
Здесь мы создаем переменную foo
и говорим, что будем хранить там целочисленные значения. При объявлении этой переменной из оперативной памяти будет выделено 4 байта или 32 бита. В 32 бита умещается число от 0 до 4 294 967 295. Это значит, что вне зависимости от того, какое число мы будем хранить в переменной (100 или 3 000 000), размер выделенной для нее памяти всегда будет составлять 4 байта. Правда, здесь стоит упомянуть, что один бит выделяется под хранение знака, а это значит, что в переменных типа int
мы можем хранить числа от -2 147 483 648 до 2 147 483 647. Такой тип данных еще называется signed int
, то есть целочисленное со знаком. Если же знак нас не интересует и мы хотим задействовать все 32 бита, то должны явно указать, что интерпретировать значение в переменной стоит как беззнаковое:
unsigned int foo = 1;
cout << foo << endl; // 1
Теперь мы можем хранить в переменной foo
числа от 0 до 4 294 967 295. Это не запрещает нам хранить там отрицательные числа, но они будут интерпретированы как беззнаковые, то есть старший бит будет считаться частью числа, а не знаком этого числа:
unsigned int foo = -100;
cout << foo << endl; // 4 294 967 196
Мы можем использовать и более «экономные» типы данных. Например, если мы планируем хранить небольшие положительные числа (до 65 535), то нам вполне подойдет тип unsigned short
или uint16
(беззнаковое целое число размером в 16 бит):
unsigned short foo = 1;
cout << sizeof foo << endl; // 2
Вывод: типизация может влиять на способ хранения и интерпретации данных.
Но не стоит путать этот эффект с типобезопасностью. В том же C++ мы можем делать страшные (с точки зрения стабильности) вещи:
string foo = "hello!";
int bar = 100;
foo = bar; // нет ошибок от компилятора
cout << foo << endl; // 'd' ???
Типизация здесь влияет на способ интерпретации данных, поэтому число 100 воспринимается как код символа (а 100 — это как раз символ d
). Хуже всего, когда добавляется какая-то семантика:
string name = "John";
int age = 40;
name = age;
cout << name << endl; // '('
С точки зрения семантики мы не хотели бы иметь возможность присваивать возраст имени, тем более что это можно сделать случайно, тем более что компилятор ничего нам об этом не говорит. Более того, если очень захотим, можем сделать и обратное преобразование:
string name = "John";
int age = 40;
void* vp = &age;
*(string*)vp = name;
cout << age << endl; // 1752123912
Это вполне легально и будет работать, вот только за результат никто не ручается. В таких языках, как C++, вы вольны делать практически все, что вам вздумается, и в данном случае мы своими руками попросили сделать нечто небезопасное.
Итак, примеры с C++ показали, что типизация здесь используется в качестве источника знаний для интерпретации значений в памяти. В конце концов, в памяти хранятся только единицы и нули, а как их интерпретировать, решаете уже вы. Так, число 4 294 967 140 может быть и символом d
, так как младший байт этого числа в двоичной системе равен 01100100(2) или 0×64(16) или 100(10), а это символ d
в таблице ASCII.
Статическая типизация
Все примеры, которые мы рассмотрели выше, относятся к статической типизации. Статическая типизация говорит, что тип переменной определяется только при ее объявлении и не может быть изменен в дальнейшем. Если мы указали, что планируем хранить в переменной число, то ничего кроме числа мы там хранить не сможем. Но что произойдет, если мы все же попытаемся это сделать? Это зависит от силы типизации. Статическая типизация бывает сильной и слабой. Вот это как раз и определяет, что произойдет при попытке присвоить значение с одним типом данных переменной с другим типом данных.
В случае со слабой типизацией будет сделана неявная попытка приведения одного типа к другому. Мы видели этот эффект в примере выше, когда пытались присвоить возраст строке и число было интерпретировано как код символа.
С/С++ — язык со слабой статической типизацией.
В случае с сильной типизацией мы получим ошибку на этапе компиляции. Посмотрим пример на языке Kotlin:
val foo: Int = 123
val bar: String = foo // Type mismatch: inferred type is Int but String was expected
А если попытаемся осуществить принудительное приведение, то рискуем получить ошибку уже в рантайме:
val foo: Int = 123
val bar: String = foo as String // java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
Тем не менее, если мы берем для примера язык Kotlin, то там можно обезопасить себя от такого выстрела в ногу — избавиться от ошибки времени выполнения и при этом ничего не сломать:
val foo: Int = 123
val bar: String? = foo as? String // нет ошибки
print(bar) // null
as?
— это safe cast operator, и он вернет null
, если преобразовать тип не удалось. Ну, а дальше мы уже знаем, что в переменной bar
может бить либо строка, либо null
, и исходя из этого будем выстраивать дальнейшую логику работы нашей программы.
Вывод: сильная статическая типизация может сделать код более безопасным.
Динамическая типизация
Переходим к JavaScript.
В языках с динамической типизацией тип переменной может быть изменен в любой момент любое количество раз:
let a = "привет";
console.log(a); // "привет"
a = 123;
console.log(a); // 123
Это значит, что такой код абсолютно легален:
function sqr(n) {
return n * n;
}
const result = sqr("привет!");
Никаких ошибок показано не будет, а в переменной result
будет записано специальное значение NaN
.
Здесь сразу же напрашивается вопрос: как мы можем быть уверены в том, что в данный момент в переменной хранится то, что мы ожидаем?
Ответ: при помощи специальных проверок прямо в момент исполнения программы:
function sqr(n) {
if (isFinite(n)) {
return n * n;
}
return 1;
}
const result = sqr("привет!");
Типов таких проверок несколько:
Но здесь есть свои нюансы, и их довольно много. Ниже приведены примеры:
typeof [] // 'object'
typeof null // 'object'
isFinite("") // true
Массивы — это не отдельный тип данных, несмотря на то, что для них есть отдельный литерал. Вот что говорит нам спецификация:
Array objects are exotic objects that give special treatment to a certain class of property names.
Так исторически сложилось.
Несмотря на то, что глобальная функция
isFinite()
проверяет, является ли переданный ей аргумент небесконечным числом, делает она это с учетом принудительного преобразования типов. Подробнее здесь (попроще) и здесь (посложнее). УisFinite()
есть более строгий вариантNumber.isFinite()
.
Справедливости ради отмечу, что не все языки с динамической типизацией замалчивают неожиданные результаты. Например, Python и PHP в некоторых случаях выбрасывают ошибку времени выполнения. Скажем, если попытаться умножить число на объект.
А теперь снова копнем ближе к внутренностям и подумаем, как хранятся значения в языках с динамической типизацией.
Тип переменной может быть изменен в любое время, а значит, и максимальный размер данных заранее неизвестен. Следовательно, мы не знаем, сколько байт памяти нам нужно выделить, чтобы удовлетворить потребности всех типов данных:
let foo = true;
// ...
foo = 1234567890;
Для хранения bool-значения достаточно одного бита, а для хранения числа 1 234 567 890 нужно выделить уже 32 бита. Каждый раз выделять память заново — дорого. Вместо этого можно попытаться описать значение в виде структуры, которая будет иметь максимально возможный размер, чтобы вместить все поддерживаемые нами типы данных. Давайте посмотрим на очень упрощенную реализацию значения в языке с динамической типизацией:
Развернуть// объединение для хранения значения
union ValueUnion {
bool boolValue; // bool-значение (1 байт)
double doubleValue; // число (8 байт)
void *objectPtr; // указатель на участок памяти (8 байт из-за 64-битной адресации памяти)
};
// поддерживаемые типы значений
enum class Type : char {
number = 1,
boolean = 2,
object = 4
};
// описание значения
struct ValueWrapper {
Type type; // тип значения (1 байт)
ValueUnion value; // значение (8 байт)
};
// набор сеттеров
void setNumber(ValueWrapper *value, double data) {
value->type = Type::number;
value->value.doubleValue = data;
}
void setBool(ValueWrapper *value, bool data) {
value->type = Type::boolean;
value->value.boolValue = data;
}
void setObjPtr(ValueWrapper *value, void *data) {
value->type = Type::object;
value->value.objectPtr = data;
}
int main() {
cout << sizeof(ValueWrapper) << endl; // 16
auto *pValueWrapper = new ValueWrapper;
// записываем число
setNumber(pValueWrapper, 100);
cout << pValueWrapper->value.doubleValue << endl; // 100
// записываем boolean
setBool(pValueWrapper, true);
cout << pValueWrapper->value.boolValue << endl; // 1
auto *strP = new string("hello!");
// записываем указатель на участок памяти
setObjPtr(pValueWrapper, strP);
cout << *(string*)pValueWrapper->value.objectPtr << endl; // hello!
return 0;
}
В этом примере мы поддерживаем три вида значений:
число
boolean
указатель на произвольный участок памяти
Объединения в C/C++ устроены так, что значения, которые мы туда записываем, делят между собой один и тот же участок памяти, а значит, размер такого объединения в байтах будет равен самому большому типу данных в объединении (в данном случае 8 байт). Этот объем памяти и выделяется при создании объединения.
Следовательно, структура, которая описывает наше значение, должна занимать 9 байт (1 байт на тип значения и 8 байт на объединение), но sizeof(ValueWrapper)
возвращает 16 вместо 9. И это нормально.
Очевидно, что при таком подходе мы платим возможностью более гибко управлять памятью из самого языка с динамической типизацией за удобства, которые дает нам эта самая динамическая типизация.
Тайпчекинг
Итак, к чему это все? Код на языках с динамической типизацией (в частности на JavaScript):
просто и быстро писать,
сложно контролировать и рефакторить,
почти не дает возможности гибко работать с памятью.
В результате с ростом кодовой базы возникает естественная потребность контролировать типы и уменьшать вероятность ошибки.
В попытке решить эти проблемы люди придумали линтеры и различные надстройки/тайпчекеры над языком, расширяющие синтаксис JS до состояния, в котором можно описывать типы. Одной из таких надстроек является TypeScript:
const foo: number = 123;
const bar: string = foo; // Type 'number' is not assignable to type 'string'
По большому счету TS дает нам возможность описать типы и проверяет, что мы их нигде явно не нарушаем, не пытаемся присвоить один тип данных другому.
На первый взгляд, TypeScript делает из JavaScript язык со строгой статической типизацией, ведь теперь мы не можем записать число в переменную, где планировали хранить строку. Да и судя по примеру выше, никаких неявных преобразований не будет, все свалится еще на этапе компиляции. Но все далеко не так однозначно.
Для начала стоит отметить, что код, который мы пишем на TS, просто трансформируется в JS, часто (но не всегда) один к одному, за исключением типов. То есть получившийся JS-код использует общий рантайм. В браузер попадает и выполняется старый добрый JS со всеми своими особенностями и отсутствием типов. Фатальный недостаток такого подхода в том, что сама по себе среда, в которой выполняется JS, открыта практически к любым изменениям:
Array.prototype.filter = () => ':(';
[1, 2, 3].filter(isEven); // ':('
С этого момента весь наш код, который завязан на стандартный метод Array#filter()
, будет сломан вне зависимости от того, насколько полно мы до этого покрыли его типами.
Развивая эту тему, давайте подумаем еще о том, что помимо типизированного и строгого кода, который пишем мы, есть еще внешний код, который может быть не типизирован:
type Foo = { a: string, b: number };
const foo: Foo = { a: "привет", b: 123 };
someExternalFunction(foo); // внешний код, написанный не нами
console.log(foo.b); // ???
Есть ли у нас хоть какая-то уверенность, что типы полей a
и b
(а точнее, типы их значений) были сохранены? Нет, абсолютно никакой. someExternalFunction
может быть вообще не типизирована и написана на чистом JS. И несмотря на то, что ее автор, возможно, предоставил d.ts
-файл с описанием этой функции, совершенно не факт, что она должным образом протестирована и вообще соответствует заявленному интерфейсу.
JS дает слишком много свободы.
Давайте посмотрим еще на один пример:
type Foo = { a: string, b: number };
function handleFoo(foo: Foo) {
// ...
}
handleFoo({a: "hello!", b: 123 }); // OK
handleFoo({a: "hello!", b: 123, c: 456 }); // Argument of type '{ a: string; b: number; c: number; }' is not assignable to parameter of type 'Foo'. Object literal may only specify known properties, and 'c' does not exist in type 'Foo'.
Вроде бы все логично, во втором случае мы пытаемся передать объект со структурой, отличной от того, что ожидается, и получаем ошибку на этапе компиляции. В то же время:
type Foo = { a: string, b: number };
type Bar = { a: string, b: number, c: number };
function handleFoo(foo: Foo) {
// ...
}
const bar: Bar = { a: "hello!", b: 123, c: 456 };
handleFoo(bar); // OK
Никаких ошибок здесь не будет, хотя по факту мы передали объект другого типа и никакой явной связи между ними нет (как, например, в случае с наследованием классов).
Документация отдельно описывает этот случай так:
Stricter object literal assignment checks
TypeScript 1.6 enforces stricter object literal assignment checks for the purpose of catching excess or misspelled properties. Specifically, when a fresh object literal is assigned to a variable or passed as an argument for a non-empty target type, it is an error for the object literal to specify properties that don«t exist in the target type.
Тем не менее такое поведение может вводить в заблуждение и приводить к неверным выводам:
type Foo = { a: string, b: number };
type Bar = { a: string, b: number, [key: string]: any};
function serializeFoo(foo: Foo) {
return JSON.stringify(foo)
}
const bar: Bar = { a: "hello!", b: 123, ...tons of properties };
serializeFoo(bar); // OK
В примере выше очень легко допустить ошибку и случайно передать в функцию не то, что нужно, понадеявшись на типизацию. В результате сериализуется избыток данных, которого быть не должно.
Так происходит из-за логики сравнения типов:
Если что-то ведет себя как утка, значит, это утка.
Такому способу сравнения типов даже выделили свое название — утиная типизация.
С другой стороны, во Flow (аналог TS в плане проверки типов) есть exact object type, что не позволит примеру выше скомпилироваться:
type Foo = {| a: string, b: number |}; // exact object type
type Bar = { a: string, b: number, c: number};
function serializeFoo(foo: Foo) {
return JSON.stringify(foo)
}
const bar: Bar = { a: "hello!", b: 123, c: 123 };
serializeFoo(bar); // Cannot call `serializeFoo` with `bar` bound to `foo` because inexact `Bar` is incompatible with exact `Foo`
Здесь вы вполне справедливо можете возразить: мол, с наследованием классов та же самая история. Посмотрим пример на Kotlin:
open class A(val foo: String)
class B(foo: String, val bar: String) : A(foo)
fun main() {
val b: B = B("foo", "bar")
val a: A = b // используем объект типа B в качества значения типа A
serializeA(a) // OK
}
Но здесь мы видим следствие полиморфизма, а не утиной типизации, так как типы явно связаны через наследование. Более того, при помощи рефлексии мы сможем выделить из объекта типа B
только поля типа A
и отправить их на сериализацию:
open class A(val foo: String)
class B(foo: String, val bar: String) : A(foo)
class PropertyInfo(val name: String, val type: KType, val value: T)
fun main() {
val b: B = B("foo value", "bar value")
val a: A = b // используем объект типа B в качества значения типа A
val items = A::class.memberProperties // получаем только поля типа A
.map { PropertyInfo(it.name, it.returnType, it.getter.call(a)) }
items.forEach { println("${it.name}: ${it.type} = ${it.value}") } // foo: kotlin.String = foo value
}
Благодаря динамичности JS структуру любого объекта можно изменить как угодно: добавить или удалить свойство, изменить его тип. Очень часто для этих целей пишут различные хелперы: marge
, renameProp
и т. п. Поэтому возникает новая необходимость: иметь возможность описать возвращаемый хелпером тип так, чтобы он автоматически выводился из типов входящих аргументов. Для этого TypeScript предлагает целый набор всяческих хелперов, keyof и infer. При необдуманном использовании они могут превратить типизированную систему в очень связный и труднораспутываемый клубок. Это отдельная тема, я постараюсь раскрыть ее как-нибудь в другой раз.
Альтернативы
В качестве альтернатив тайпчекингу через TypeScript/Flow можно рассмотреть такие языки, как Dart или Kotlin, которые не просто трансформируются в JS, но еще и тащат за собой свой рантайм. Это значит, что, например, для фильтрации массива будет использована собственная реализация языка, а не встроенный метод Array#filter
и т. д. С одной стороны, это позволяет создать эдакую песочницу, которая изолирована от общего рантайма и реализовывает свои собственные методы для работы с массивами, строками, объектами и т. д. С другой, даже при таком подходе невозможно полностью уйти от внешнего влияния общего рантайма, который может быть изменен в любой момент. Но несомненно, такой подход сильно снижает связь с внешней средой и код становится более безопасным, правда, до тех пор, пока мы не начнем вызывать из нашей замечательной изолированной коробочки внешний код, написанный не нами. Например, это не спасет нас от манкипатчинга какого-нибудь базового конструктора типа Array
.
Другой альтернативный вариант — WASM. Пишите код на любом языке (с нужной вам типизацией, строгостью и т. д.), превращайте его в бинарный wasm-файл и запускайте в браузере. Здесь вы сами себе рантайм. Абсолютно все придется реализовывать самостоятельно (или использовать имплементацию языка, на котором вы пишете).
Выводы
Нужно четко понимать, чего ожидать от типизации, какие задачи она решает, а какие нет (особенно когда дело касается очень свободного языка, такого как JS).
Так какую же тогда проблему решает TypeScript (и ему подобные) и какие ожидания по отношению к нему оправданны?
TypeScript помогает контролировать типы внутри того кода, который вы сами написали, но как только дело доходит до работы с внешним кодом, то здесь уже без каких-либо гарантий. Как только вы в коде используете нечто внешнее, что угодно может пойти не так. Более того, рассчитывать на типы в своем коде вы можете только до старта приложения. Как только ваш код запущен внутри нестабильной среды (коей является рантайм JS), то, опять же, что угодно может пойти не так.
Все это совсем не означает, что тайпчекинг для JS плох и бесполезен, просто нужно понимать, чего от него стоит ожидать и какие задачи он точно не решает.
В качестве очень приятного бонуса можно отметить, что тулинг того же TypeScript помогает в таких важных вещах, как навигация по коду, code completion и рефакторинг.
Может показаться, что я немного перегнул с тягой к полной изоляции. Дело в том, что JS уже столько лет дает нам настолько большую свободу действий, что отсутствие такой изоляции принимается как само собой разумеющееся. Это ведет к снижению безопасности и стабильности в работе нашего кода, и тайпчекинг не решает эту проблему. Языки, которые трансформируются в JS и тянут за собой свой рантайм, с одной стороны, повышают изоляцию, с другой — всегда остается «дыра» на стыке с внешним кодом.