TypeScript позволяет нам украшать совместимый со спецификацией ECMAScript с информацией о типах, которую мы можем анализировать и выводить как обычный JavaScript, используя выделенный компилятор. В крупных проектах такого рода статический анализ может выявить потенциальные ошибки, прежде чем прибегнуть к длительным сеансам отладки, не говоря уже о развертывании в рабочей среде. Однако ссылочные типы в TypeScript все еще являются изменяемыми, что может привести к непреднамеренным побочным эффектам в нашем программном обеспечении.
В этой статье мы рассмотрим возможные конструкции, в которых запрет на изменение ссылок может быть полезным.
Нужно освежить в неизменяемости в JavaScript? Прочитайте наше руководство, Неизменность в JavaScript .
Примитивы против типов ссылок
JavaScript определяет две всеобъемлющие группы типов данных :
- Примитивы: значения низкого уровня, которые являются неизменяемыми (например, строки, числа, логические значения и т. Д.)
- Ссылки: коллекции свойств, представляющих идентифицируемую память кучи, которые могут изменяться (например, объекты, массивы,
Map
Скажем, мы объявляем константу, которой мы присваиваем строку:
const message = 'hello';
Учитывая, что строки являются примитивами и поэтому являются неизменяемыми, мы не можем напрямую изменить это значение. Он может использоваться только для создания новых значений:
console.log(message.replace('h', 'sm')); // 'smello'
console.log(message); // 'hello'
Несмотря на вызов replace()
message
Мы просто создаем новую строку, оставляя исходное содержимое message
Отключение индексов message
строгом режиме выдает ошибку TypeError:
TypeError
Обратите внимание, что если в объявлении 'use strict';
const message = ‘hello’;
message[0] = ‘j’; // TypeError: 0 is read-only
message
let
Важно подчеркнуть, что это не мутация. Вместо этого мы заменяем одно неизменное значение другим.
Изменчивые ссылки
Давайте сопоставим поведение примитивов со ссылками. Давайте объявим объект с парой свойств:
let message = 'hello';
message = 'goodbye';
Учитывая, что объекты JavaScript являются изменяемыми, мы можем изменить его существующие свойства и добавить новые:
const me = {
name: 'James',
age: 29,
};
В отличие от примитивов, объекты могут быть напрямую видоизменены без замены новой ссылкой. Мы можем доказать это, разделяя один объект между двумя объявлениями:
me.name = 'Rob';
me.isTall = true;
console.log(me); // Object { name: "Rob", age: 29, isTall: true };
Массивы JavaScript, которые наследуются от const me = {
name: 'James',
age: 29,
};
const rob = me;
rob.name = ‘Rob’;
console.log(me); // { name: ‘Rob’, age: 29 }
Object.prototype
В чем проблема с изменяемыми ссылками?
Предположим, у нас есть изменяемый массив первых пяти чисел Фибоначчи :
const names = ['James', 'Sarah', 'Rob'];
names[2] = 'Layla';
console.log(names); // Array(3) [ 'James', 'Sarah', 'Layla' ]
Этот код может показаться безобидным на const fibonacci = [1, 2, 3, 5, 8];
log2(fibonacci); // replaces each item, n, with Math.log2(n);
appendFibonacci(fibonacci, 5, 5); // appends the next five Fibonacci numbers to the input array
log2
Вместо этого fibonacci
fibonacci
Следовательно, можно утверждать, что названия этих объявлений семантически неточны, что усложняет процесс выполнения программы.
Псевдо-неизменяемые объекты в JavaScript
Хотя объекты JavaScript являются изменяемыми, мы можем использовать преимущества конкретных конструкций для глубоких ссылок на клоны, а именно: распространенный синтаксис :
[0, 1, 1.584962500721156, 2.321928094887362, 3, 13, 21, 34, 55, 89]
Синтаксис распространения также совместим с массивами:
const me = {
name: 'James',
age: 29,
address: {
house: '123',
street: 'Fake Street',
town: 'Fakesville',
country: 'United States',
zip: 12345,
},
};
const rob = {
...me,
name: 'Rob',
address: {
...me.address,
house: '125',
},
};
console.log(me.name); // 'James'
console.log(rob.name); // 'Rob'
console.log(me === rob); // false
Неизменное мышление при работе со ссылочными типами может сделать поведение нашего кода более понятным. Возвращаясь к предыдущему изменяемому примеру Фибоначчи, мы могли бы избежать такой мутации, скопировав Фибоначчи в новый массив:
const names = ['James', 'Sarah', 'Rob'];
const newNames = [...names.slice(0, 2), 'Layla'];
console.log(names); // Array(3) [ 'James', 'Sarah', 'Rob' ]
console.log(newNames); // Array(3) [ 'James', 'Sarah', 'Layla' ]
console.log(names === newNames); // false
Вместо того, чтобы возлагать бремя создания копий на потребителя, было бы предпочтительно, чтобы fibonacci
const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = [...fibonacci];
log2(log2Fibonacci);
appendFibonacci(fibonacci, 5, 5);
log2
Путем написания наших функций для возврата новых ссылок в пользу изменения их входных данных массив, идентифицируемый объявлением appendFibonacci
В конечном счете, этот код является более детерминированным .
Окрашивание трещин
С некоторой дисциплиной мы можем действовать по ссылкам, как если бы они были исключительно читабельными, но они не позволяли мутации происходить в другом месте. Что может помешать нам ввести мошенническое утверждение для изменения const PHI = 1.618033988749895;
const log2 = (arr: number[]) => arr.map(n => Math.log2(2));
const fib = (n: number) => (PHI ** n — (—PHI) ** —n) / Math.sqrt(5);
const createFibSequence = (start = 0, length = 5) =>
new Array(length).fill(0).map((_, i) => fib(start + i + 2));
const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = log2(fibonacci);
const extendedFibSequence = […fibonacci, …createFibSequence(5, 5)];
fibonacci
ECMAScript 5 представил fibonacci
fibonacci.push(4);
К сожалению, он только поверхностно запрещает изменение свойств, и поэтому вложенные объекты все еще могут быть изменены:
Object.freeze()
Можно вызвать этот метод для всех объектов в определенном дереве, но это быстро оказывается громоздким. Возможно, мы могли бы вместо этого использовать возможности TypeScript для неизменности во время компиляции.
Глубоко замораживание буквальных выражений с константными утверждениями
В TypeScript мы можем использовать константные утверждения , расширения утверждений типа , для вычисления глубокого, доступного только для чтения типа из литерального выражения:
'use strict';
const me = Object.freeze({
name: 'James',
age: 29,
address: {
// props from earlier example
},
});
me.name = 'Rob'; // TypeError: 'name' is read-only
me.isTheBest = true; // TypeError: Object is not extensible
Аннотирование этого литерального выражения объекта // No TypeErrors will be thrown
me.address.house = '666';
me.address.foo = 'bar';
const sitepoint = {
name: 'SitePoint',
isRegistered: true,
address: {
line1: 'PO Box 1115',
town: 'Collingwood',
region: 'VIC',
postcode: '3066',
country: 'Australia',
},
contentTags: ['JavaScript', 'HTML', 'CSS', 'React'],
} as const;
Другими словами:
- Открытые примитивы будут сужены до точных литеральных типов (например,
as const
{
readonly name: 'SitePoint';
readonly isRegistered: true;
readonly address: {
readonly line1: 'PO Box 1115';
readonly town: 'Collingwood';
readonly region: 'VIC';
readonly postcode: '3066';
readonly country: 'Australia';
};
readonly contentTags: readonly ['JavaScript', 'HTML', 'CSS', 'React'];
}
- Объектные литералы будут изменять свои свойства с помощью
boolean
- Литералы массива станут кортежами только для
true
readonly
readonly
Попытка добавить или заменить любые значения приведет к тому, что компилятор TypeScript выдаст ошибку:
string[]
Константные утверждения приводят к типам только для чтения, которые по сути запрещают вызов любых методов экземпляра, которые будут мутировать объект:
['foo', 'bar', 'baz']
Естественно, единственным способом использования неизменяемых объектов для отражения различных значений является создание из них новых объектов:
sitepoint.isCharity = true; // isCharity does not exist on inferred type
sitepoint.address.country = 'United Kingdom'; // Cannot assign to 'country' because it is a read-only property
Неизменные функциональные параметры
Поскольку константные утверждения являются просто синтаксическим сахаром для типизации определенного объявления как набора свойств только для чтения с литеральными значениями, все еще возможно изменять ссылки в теле функций:
sitepoint.contentTags.push('Pascal'); // Property 'push' does not exist on type 'readonly ["JavaScript", "HTML"...]
Это можно решить, const microsoft = {
...sitepoint,
name: 'Microsoft',
} as const;
interface Person {
name: string;
address: {
country: string;
};
}
const me = {
name: ‘James’,
address: {
country: ‘United Kingdom’,
},
} as const;
const isJames = (person: Person) => {
person.name = ‘Sarah’;
return person.name === ‘James’;
};
console.log(isJames(me)); // false;
console.log(me.name); // ‘Sarah’;
person
Readonly<Person>
Нет встроенных типов утилит для обработки глубокой неизменности, но, учитывая, что TypeScript 3.7 обеспечивает лучшую поддержку рекурсивных типов , откладывая их разрешение, мы можем теперь выразить бесконечно рекурсивный тип, чтобы обозначать свойства как const isJames = (person: Readonly<Person>) => {
person.name = 'Sarah'; // Cannot assign to 'name' because it is a read-only property.
person.address.country = 'Australia'; // valid
return person.name === 'James';
};
console.log(isJames(me)); // false
console.log(me.address.country); // ‘Australia’
readonly
type Immutable<T> = {
readonly [K in keyof T]: Immutable<T[K]>;
};
Если бы мы описали параметр person
isJames()
Immutable<Person>
const isJames = (person: Immutable<Person>) => {
person.name = 'Sarah'; // Cannot assign to 'name' because it is a read-only property.
person.address.country = 'Australia'; // Cannot assign to 'country' because it is a read-only property.
return person.name === 'James';
};
Это решение также будет работать для глубоко вложенных массивов:
const hasCell = (cells: Immutable<string[][]>) => {
cells[0][0] = 'no'; // Index signature in type 'readonly string[]' only permits reading.
};
Несмотря на то, что Immutable<T>
продолжаются дискуссии о внедрении DeepReadonly <T> в TypeScript , который имеет более утонченную семантику.
Пример из реального мира
Redux , чрезвычайно популярная библиотека управления состоянием, требует неизменной обработки состояния, чтобы тривиально определить, нужно ли обновлять хранилище. У нас могут быть интерфейсы состояния приложения и действия, похожие на это:
interface Action {
type: string;
name: string;
isComplete: boolean;
}
interface Todo {
name: string;
isComplete: boolean;
}
interface State {
todos: Todo[];
}
Учитывая, что наш редуктор должен возвращать совершенно новую ссылку, если состояние было обновлено, мы можем ввести аргумент state
Immutable<State>
const reducer = (
state: Immutable<State>,
action: Immutable<Action>,
): Immutable<State> => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
name: action.name,
isComplete: false,
},
],
};
default:
return state;
}
};
Дополнительные преимущества неизменяемости
В этой статье мы наблюдали, как обработка объектов неизменно приводит к более ясному и детерминированному коду. Тем не менее, есть пара дополнительных преимуществ, которые стоит поднять.
Обнаружение изменений с помощью оператора строгого сравнения
В JavaScript мы можем использовать оператор строгого сравнения ( ===
Рассмотрим наш редуктор в предыдущем примере:
const reducer = (
state: Immutable<State>,
action: Immutable<TodoAction>,
): Immutable<State> => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
// deeply merge TODOs
};
default:
return state;
}
};
Поскольку мы создаем новую ссылку, только если вычислено измененное состояние, мы можем сделать вывод, что строгое равенство ссылок представляет неизмененный объект:
const action = {
...addTodoAction,
type: 'NOOP',
};
const newState = reducer(state, action);
const hasStateChanged = state !== newState;
Обнаружение изменений по строгому равенству ссылок проще, чем глубокое сравнение двух деревьев объектов, которое обычно включает в себя рекурсию.
Запоминание вычислений по ссылке
В качестве следствия для обработки ссылок и выражений объектов как отношения «один к одному» (т. Е. Одна ссылка представляет точный набор свойств и значений), мы можем запоминать потенциально дорогостоящие вычисления по ссылке. Если бы мы хотели добавить массив, содержащий первые 2000 чисел последовательности Фибоначчи, мы могли бы использовать функцию более высокого порядка и WeakMap
для предсказуемого кеширования результата операции по определенной ссылке:
const memoise = <TArg extends object, TResult>(func: Function) => {
const results = new WeakMap<TArg, TResult>();
return (arg: TArg) =>
results.has(arg) ? results.get(arg) : results.set(arg, func(arg)).get(arg);
};
const sum = (numbers: number[]) => numbers.reduce((total, x) => total + x, 0);
const memoisedSum = memoise<number[], number>(sum);
const numbers = createFibSequence(0, 2000);
console.log(memoisedSum(numbers)); // Cache miss
console.log(memoisedSum(numbers)); // Cache hit
Неизменность — это не серебряная пуля
Как и любая парадигма программирования, неизменность имеет свои недостатки:
- Копирование глубинных объектов с синтаксисом распространения может быть многословным, особенно когда изменяется только одно значение примитива в сложном дереве.
- Создание новых ссылок приведет ко многим эфемерным выделениям памяти, которые, следовательно, должна собирать сборка мусора ;. Это может перебить основной поток, хотя современные сборщики мусора, такие как Orinoco, смягчают это с помощью распараллеливания.
- Использование неизменяемых типов и константных утверждений требует дисциплины и межгруппового консенсуса. В качестве средства автоматизации такой практики обсуждаются конкретные правила оформления , но они представляют собой очень ранние предложения.
- Многие первые и сторонние API, такие как DOM и аналитические библиотеки, смоделированы на основе мутации объектов. Хотя конкретные рефераты могут помочь, повсеместная неизменность в сети невозможна.
Резюме
Мутационный код может иметь непрозрачные намерения и привести к тому, что наше программное обеспечение будет работать неожиданно. Манипулирование современным синтаксисом JavaScript может побудить разработчиков постоянно работать с ссылочными типами — создавать новые объекты из существующих ссылок вместо их прямого изменения — и дополнять их конструкциями TypeScript для достижения неизменности во время компиляции. Это, конечно, не надежный подход, но с некоторой дисциплиной мы можем написать чрезвычайно надежные и предсказуемые приложения, которые в долгосрочной перспективе могут только облегчить нашу работу.