javascript

Зачем использовать статические типы в JavaScript? (Преимущества и недостатки)

  • пятница, 14 апреля 2017 г. в 03:14:56
https://habrahabr.ru/post/326394/
  • Семантика
  • Проектирование и рефакторинг
  • Программирование
  • JavaScript


Мы о многом рассказали в первой части. Теперь с синтаксисом покончено, давайте наконец перейдём к самому интересному: изучению преимуществ и недостатков использования статических типов.

Преимущество № 1: Вы можете заблаговременно находить баги и ошибки


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

Небольшой пример: предположим, что у нас небольшая функция, которая берёт радиус и вычисляет площадь:

const calculateArea = (radius) => 3.14 * radius * radius;

var area = calculateArea(3);
// 28.26

Теперь если мы захотим передать функции радиус, который не является числом (типа «злоумышленник»)…

var area = calculateArea('im evil');
// NaN

Нам вернётся NaN. Если какая-то функциональность основана на том, что функция calculateArea всегда возвращает число, то это приведёт к уязвимости или сбою. Не очень приятно, правда?

Если бы мы использовали статические типы, то определили бы конкретный тип передаваемых параметров и возвращаемых значений для этой функции:

const calculateArea = (radius: number): number => 3.14 * radius * radius;

Попробуйте теперь передать что-нибудь кроме числа функции calculateArea — и Flow вернёт удобное и симпатичное сообщение:

calculateArea('Im evil');
^^^^^^^^^^^^^^^^^^^^^^^^^ function call
calculateArea('Im evil');
              ^^^^^^^^^ string. This type is incompatible with 
                
const calculateArea = (radius: number): number => 3.14 * radius * radius;
                               ^^^^^^ number

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

Поскольку контролёр типов сообщает вам об ошибках прямо во время написания кода, это намного удобнее (и намного дешевле), чем поиск бага после того, как код отправлен заказчику.

Преимущество № 2: У вас появляется живая документация


Типы работают как живая, дышащая документация и для вас, и для других.

Чтобы понять каким образом, посмотрим на метод, который я однажды нашла в большой кодовой базе, с которой работала:

function calculatePayoutDate(quote, amount, paymentMethod) {
  let payoutDate;
  
  /* business logic */

  return payoutDate;
}

На первый взгляд (и на второй, и на третий), совершенно непонятно, как использовать эту функцию.

Является ли quote числом? Или логическим значением? Платёжный метод — это объект? Или это может быть строка, которая представляет тип платёжного метода? Возвращает ли функция дату в строковом виде? Или это объект Date?

Без понятия.

В то время я решила провести оценку всей деловой логики и делала grep по кодовой базе до тех пор, пока всё не выяснила. Но это слишком много работы только для того, чтобы понять, как работает простая функция.

С другой стороны, если бы написали что-то вроде такого:


function calculatePayoutDate(
  quote: boolean,
  amount: number,
  paymentMethod: string): Date {
  let payoutDate;
    
  /* business logic */

  return payoutDate;
}

то немедленно стало бы ясно, какой тип данных функция принимает, а какой тип возвращает. Это пример того, как можно использовать статические типы для сообщения того, что функция намерена делать. Мы можем сообщить другим разработчикам, чего ожидаем от них, и можем увидеть, чего они ожидают от нас. Следующий раз, если кто-то соберётся использовать эту функцию, вопросов не возникнет.

Можно поспорить, что эта проблема решается добавлением комментариев к коду или документации:

/*
  @function Determines the payout date for a purchase
  @param {boolean} quote - Is this for a price quote?
  @param {boolean} amount - Purchase amount
  @param {string} paymentMethod - Type of payment method used for this purchase
*/
function calculatePayoutDate(quote, amount, paymentMethod) {
  let payoutDate;
  /* .... Business logic .... */

  return payoutDate;
};

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

Особенно легко забыть обновить комментарий после рефакторинга. С другой стороны, у аннотаций типов чётко определённый синтаксис и структура, и они никогда не устареют, потому что закодированы в самом коде.

Преимущество № 3: Устраняется обработка запутанных ошибок


Типы помогают устранить обработку в коде запутанных ошибок. Давайте вернёмся к нашей функции calculateArea и посмотрим, каким образом это происходит.

На этот раз я передам ей массив радиусов для вычисления площадей для каждого радиуса:


const calculateAreas = (radii) => {
  var areas = [];
  for (let i = 0; i < radii.length; i++) {
    areas[i] = PI * (radii[i] * radii[i]);
  }

  return areas;
};

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

const calculateAreas = (radii) => {
  // Handle undefined or null input
  if (!radii) {
    throw new Error("Argument is missing");
  }

  // Handle non-array inputs
  if (!Array.isArray(radii)) {
    throw new Error("Argument must be an array");
  }

  var areas = [];

  for (var i = 0; i < radii.length; i++) {
    if (typeof radii[i] !== "number") {
      throw new Error("Array must contain valid numbers only");
    } else {
      areas[i] = 3.14 * (radii[i] * radii[i]);
    }
  }

  return areas;
};

Ого. Тут много кода для такого маленького кусочка функциональности.

А со статическими типами мы просто напишем:

const calculateAreas = (radii: Array<number>): Array<number> => {
  var areas = [];
  for (var i = 0; i < radii.length; i++) {
    areas[i] = 3.14 * (radii[i] * radii[i]);
  }

  return areas;
};

Теперь функция действительно выглядит так, как она выглядела перед добавлением всего визуального мусора из-за обработки ошибок.

Легко понять преимущества статических типов, правда?

Преимущество № 4: Вы можете увереннее осуществлять рефакторинг


Объясню это историей из жизни. Однажды я работала с очень большой кодовой базой, и нужно было обновить метод, установленный в классе User. В частности, изменить один из параметров функции со string на object.

Я произвела изменение, но мне было страшно отправлять коммит — по всему коду разбросано так много вызовов к этой функции, что я не была уверена, что правильно обновила все экземпляры. Что если какой-то вызов остался где-то глубоко в непроверенном вспомогательном файле?

Единственный способ проверить — это отправить код и молиться, что он не взорвётся кучей ошибок.

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

Преимущество № 5: Разделение данных и поведения


Одно редко упоминаемое преимущество статических типов состоит в том, что они помогают отделяют данные от поведения.

Ещё раз посмотрим на нашу функцию calculateArea со статическими типами:

const calculateAreas = (radii: Array<number>): Array<number> => {
  var areas = [];
  for (var i = 0; i < radii.length; i++) {
    areas[i] = 3.14 * (radii[i] * radii[i]);
  }

  return areas;
};

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



Только после этого мы реализуем логику:



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

Преимущество № 6: Устранение целой категории багов


Ошибки типов во время выполнения программы — одна из самых распространённых ошибок или багов, с которыми сталкиваются JavaScript-разработчики.

Например, предположим, что изначальное состояние приложения было установлено так:

var appState = {
  isFetching: false,
  messages: [],
};

И предположим, что затем мы делаем вызов API, чтобы забрать сообщения и заполнить наш appState. Далее, у нашего приложения есть чрезмерно упрощённый компонент для просмотра, который забирает messages (указанные в состоянии выше) и отображает количество непрочитанных сообщений и каждое сообщение как элемент списка:

import Message from './Message';

const MyComponent = ({ messages }) => {
  return (
    <div>
      <h1> You have { messages.length } unread messages </h1>
      { messages.map(message => <Message message={ message } /> )}
    </div>
  );
};

Если вызов API для забора сообщений не сработал или вернул undefined, то вы столкнётесь с ошибкой типа в продакшне:

TypeError: Cannot read property ‘length’ of undefined

…и ваша программа завершится со сбоем. Вы потеряете клиента. Занавес.

Посмотрим, как могут помочь статические типы. Начнём с добавления типов Flow к состоянию приложения. Я использую псевдоним типа AppState для определения состояния:

type AppState = {
  isFetching: boolean,
  messages: ?Array<string>
};

var appState: AppState = {
  isFetching: false,
  messages: null,
};

Поскольку известно, что API для забора сообщений работают ненадёжно, то укажем для значения messages тип maybe для массива строк.

Так же как в прошлый раз, мы забираем сообщения через ненадёжный API и используем их в компоненте просмотра:

import Message from './Message';

const MyComponent = ({ messages }) => {
  return (
    <div>
      <h1> You have { messages.length } unread messages </h1>
      { messages.map(message => <Message message={ message } /> )}
    </div>
  );
};

Но в этот момент Flow обнаружит ошибку и пожалуется:

<h1> You have {messages.length} unread  messages </h1>
                        ^^^^^^ property `length`. Property cannot be accessed on possibly null value                                                                  
<h1> You have {messages.length} unread messages </h1>
               ^^^^^^^^ null

<h1> You have {messages.length} unread  messages </h1>
                        ^^^^^^ property `length`. Property cannot be accessed on possibly undefined value

<h1> You have {messages.length} unread messages </h1>
               ^^^^^^^^ undefined

     { messages.map(message => <Message message={ message } /> )}
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call of method `map`. Method cannot be called on possibly null value

     { messages.map(message => <Message message={ message } /> )}
       ^^^^^^^^ null

     { messages.map(message => <Message message={ message } /> )}
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call of method `map`. Method cannot be called on possibly undefined value

     { messages.map(message => <Message message={ message } /> )}
       ^^^^^^^^ undefined

Погоди, приятель!

Поскольку мы определили messages как тип maybe, мы разрешаем ему быть null или undefined. Но это не даёт нам права проводить операции с ним (вроде .length или .map) без осуществления проверки на null, потому что если значение messages на самом деле null или undefined, то выскочит ошибка типа при попытке проведения операции с ним.

Так что вернёмся и обновим нашу функцию для просмотра примерно таким образом:

const MyComponent = ({ messages, isFetching }: AppState) => {
  if (isFetching) {
    return <div> Loading... </div>
  } else if (messages === null || messages === undefined) {
    return <div> Failed to load messages. Try again. </div>
  } else {
    return (
      <div>
        <h1> You have { messages.length } unread messages </h1>
        { messages.map(message => <Message message={ message } /> )}
      </div>
    );
  }
};

Теперь Flow знает, что мы учли все ситуации, где messages равно null или undefined, так что проверка типов кода завершается с 0 ошибок. Прощайте, ошибки во время выполнения программы!

Преимущество № 7: Уменьшение количества юнит-тестов


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

Например, вернёмся к нашей функции calculateAreas с динамическими типами и обработкой ошибок.

const calculateAreas = (radii) => {
  // Handle undefined or null input
  if (!radii) {
    throw new Error("Argument is missing");
  }

  // Handle non-array inputs
  if (!Array.isArray(radii)) {
    throw new Error("Argument must be an array");
  }

  var areas = [];

  for (var i = 0; i < radii.length; i++) {
    if (typeof radii[i] !== "number") {
      throw new Error("Array must contain valid numbers only");
    } else {
      areas[i] = 3.14 * (radii[i] * radii[i]);
    }
  }

  return areas;
};

Если бы мы были прилежными программистами, то могли бы подумать о тестировании недействительных передаваемых параметров для проверки, что они корректно обрабатываются нашей программой:


it('should not work - case 1', () => {
  expect(() => calculateAreas([null, 1.2])).to.throw(Error);
});

it('should not work - case 2', () => {
  expect(() => calculateAreas(undefined).to.throw(Error);
});

it('should not work - case 2', () => {
  expect(() => calculateAreas('hello')).to.throw(Error);
});

… и так далее. Но очень вероятно, что мы забудем протестировать какие-то граничные случаи, — и наш заказчик будет тем, кто обнаружит проблему. :(

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

С другой стороны, когда нам требуется установить типы:

const calculateAreas = (radii: Array<number>): Array<number> => {
  var areas = [];
  for (var i = 0; i < radii.length; i++) {
    areas[i] = 3.14 * (radii[i] * radii[i]);
  }

  return areas;
};

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

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

Преимущество № 8: Инструмент моделирования предметной области


Один из моих любимых примеров использования статичных типов — моделирование предметной области (domain modeling). В этом случае создаётся модель, которая включает в себя и данные, и поведение программы на этих данных. В данном случае лучше всего понять на примере, как использовать типы.

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

Итак, сначала применим псевдонимы типов для трёх платёжных методов:


type Paypal = { id: number, type: 'Paypal' };
type CreditCard = { id: number, type: 'CreditCard' };
type Bank = { id: number, type: 'Bank' };

Теперь можно установить тип PaymentMethod как непересекающееся множество с тремя случаями:

type PaymentMethod = Paypal | CreditCard | Bank;

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


type Model = {
  paymentMethods: Array<PaymentMethod>
};

Это приемлемо? Ну, мы знаем, что для получения платёжных методов пользователя нужно сделать запрос к API и, в зависимости от результата и этапа процесса, приложение может принимать разные состояния. В реальности, возможно четыре состояния:

1) Мы не получили платёжные методы.
2) Мы в процессе получения платёжных методов.
3) Мы успешно получили платёжные методы.
4) Мы попытались получить платёжные методы, но возникла ошибка.

Но наш простой тип Model с paymentMethods не покрывает все эти случаи. Вместо этого он предполагает, что paymentMethods всегда существует.

Хм-м-м. Существует ли способ составить модель, чтобы состояние приложения принимало одно из этих четырёх значений, и только их? Давайте посмотрим:

type AppState<E, D>
  = { type: 'NotFetched' }
  | { type: 'Fetching' }
  | { type: 'Failure', error: E }
  | { type: 'Success', paymentMethods: Array<D> };

Мы использовали тип непересекающегося множества для установки AppState в одно из четырёх состояний, описанных выше. Заметьте, как я использую свойство type для определения, в каком из четырёх состояний находится приложение. Именно это свойство type и является тем, что создаёт непересекающееся множество. Используя его мы можем осуществить анализ и определить, когда у нас есть платёжные методы, а когда нет.

Вы также заметите, что я передаю параметризованный тип E и D в состояние приложения. Тип D будет представлять собой платёжный метод пользователя (PaymentMethod, определённый выше). Мы не установили тип E, который будет нашим типом для ошибки, так что сделаем это сейчас:


type HttpError = { id: string, message: string };

Теперь можно смоделировать предметную область приложения:

type Model = AppState<HttpError, PaymentMethod>;

В целом, подпись для состояния приложения теперь AppState<E, D>, где E имеет форму HttpError, а D — это PaymentMethod. И у AppState есть четыре (и только эти четыре) возможных состояния: NotFetched, Fetching, Failure и Success.



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

Более того, код документирует сам себя — достаточно посмотреть на непересекающиеся множества, и немедленно становится понятно, как структурирован AppState.

Недостатки использования статических типов


Как и всё остальное в жизни и программировании, проверка статических типов требует некоторых компромиссов.

Важно понять и признать эти недостатки, чтобы мы могли принять информированное решение, когда имеет смысл использовать статические типы, а когда они просто не стоят того.

Вот некоторые из этих соображений:

Недостаток № 1: Статические типы требуют предварительного изучения


Одна причина, почему JavaScript — такой фантастический язык для начинающих, состоит в том, что новичкам не требуется изучать полную систему типов перед началом продуктивной работы.

Когда я только выучила Elm (функциональный язык со статической типизацией), типы часто мешали. Я постоянно сталкивалась с ошибками компилятора из-за своих определений типов.

Изучение эффективного использования типов — это была половина успеха в изучении самого языка. В итоге, из-за статических типов кривая обучения Elm круче, чем у JavaScript.

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

Недостаток № 2: Можно увязнуть в многословии


Из-за статических типов программы часто выглядят более многословными и загромождёнными.

Например, вместо этого:

async function amountExceedsPurchaseLimit(amount, getPurchaseLimit){
  var limit = await getPurchaseLimit();

  return limit > amount;
}

Нам приходится писать:

async function amountExceedsPurchaseLimit(
  amount: number,
  getPurchaseLimit: () => Promise<number>
): Promise<boolean> {
  var limit = await getPurchaseLimit();

  return limit > amount;
}

А вместо этого:

var user = {
  id: 123456,
  name: 'Preethi',
  city: 'San Francisco',
};

Приходится писать такое:

type User = {
  id: number,
  name: string,
  city: string,
};

var user: User = {
  id: 123456,
  name: 'Preethi',
  city: 'San Francisco',
};

Очевидно, что добавляются лишние строки кода. Но есть парочка аргументов против того, чтобы считать это недостатком.

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

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

Сложно сказать, является ли многословность реальным аргументом против типов, но стóит держать его в уме.

Недостаток № 3: Требуется время для достижения мастерства в использовании типов


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

Например, один из подходов такой: закодировать критическиую бизнес-логику статическими типами, но оставить краткосрочные или неважные фрагменты логики динамическими, чтобы избежать излишней сложности.

Понять разницу бывает трудно, особенно если менее опытному разработчику приходится принимать решения на лету.

Недостаток № 4: Статические типы могут задержать быструю разработку


Как я упоминала ранее, я слегка споткнулась о типы, когда изучала Elm — особенно когда добавляла код или делала изменения в нём. Постоянно отвлекаясь на ошибки компилятора, трудно делать работу и чувствовать прогресс.

Здесь аргумент в том, что из-за проверки статических типов программист может слишком часто терять концентрацию — а вы знаете, концентрация является ключевым фактором для написания хорошей программы.

Дело не только в этом. Контролёры статических типов тоже не всегда идеальны. Иногда возникает ситуация, когда вы знаете что делать, а проверка типов вмешивается и мешает.

Уверена, что я упустила какие-то ещё недостатки, но это самые важные для меня.

Нужно использовать статические типы в JavaScript или нет?




Первыми языками программирования, которые я изучила, были JavaScript и Python, оба языка с динамической типизацией.

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

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

Динамические типы кажутся более быстрыми и простыми, но они могут подвести, когда вы реально запускаете программу в деле. В то же время можете поговорить с любым Java-разработчиком, который имеет дело с более сложными параметризованными типам — и он скажет, как сильно их ненавидит.

В конечном счёте, здесь нет универсального решения. Лично я предпочитают использовать статические типы при следующих условиях:

  1. Программа критически важна для вашего бизнеса.
  2. Программа, вероятно, подвергнется рефакторингу, в соответствии с новыми потребностями.
  3. Программа сложна и имеет много подвижных частей.
  4. Программу поддерживает большая группа разработчиков, которым нужно быстро и точно понять код.

С другой стороны, я бы отказалась от статических типов в следующих условиях:

  1. Код недолговечный и не является критически важным.
  2. Вы делаете прототип и стараетесь продвигаться как можно быстрее.
  3. Программа маленькая и/или простая.
  4. Вы единственный разработчик.

Преимущество разработки на JavaScript в наши дни состоит в том, что благодаря инструментам вроде Flow и TypeScript у нас наконец-то появился выбор — использовать статические типы или старый добрый JavaScript.

Заключение


Надеюсь, эти статьи помогли вам понять важность типов, как их использовать и, самое главное, *когда* их использовать.

Возможность переключаться между динамическими и статическими типами — это мощный инструмент для JavaScript-сообщества, и захватывающий :)

Об авторе: Прити Касиредди (Preethi Kasireddy), сооснователь и ведущий инженер компании Sapien AI, Калифорния