Статьи

Неизменность времени компиляции в TypeScript

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 Вместо этого fibonaccifibonacci Следовательно, можно утверждать, что названия этих объявлений семантически неточны, что усложняет процесс выполнения программы.

Псевдо-неизменяемые объекты в 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

Вместо того, чтобы возлагать бремя создания копий на потребителя, было бы предпочтительно, чтобы fibonacciconst 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
  • Литералы массива станут кортежами только для truereadonlyreadonly

Попытка добавить или заменить любые значения приведет к тому, что компилятор 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]>;
};

Если бы мы описали параметр personisJames()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[];
}

Учитывая, что наш редуктор должен возвращать совершенно новую ссылку, если состояние было обновлено, мы можем ввести аргумент stateImmutable<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 для достижения неизменности во время компиляции. Это, конечно, не надежный подход, но с некоторой дисциплиной мы можем написать чрезвычайно надежные и предсказуемые приложения, которые в долгосрочной перспективе могут только облегчить нашу работу.