[Перевод] Я до последнего буду защищать сильную статическую типизацию

d74a7c90c98fc77f8855f3f83973d624.jpg

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

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

У неиспользования типов есть преимущества, например, более быстрая разработка, но они бледнеют по сравнению со всеми преимуществами типизации. Я говорю так:

Написание ПО без типов позволяет двигаться на полной скорости. На полной скорости к обрыву.

Вопрос о сильной статической типизации прост: выберете ли вы поработать чуть больше, но чтобы инварианты при этом проверялись во время компиляции (или на этапе проверки типов для некомпилируемых языков), или работать чуть меньше, но чтобы они принудительно применялись в среде исполнения или хуже того, не применялись вообще (да, JavaScript, я намекаю на тебя… 1 + "2" == 12).

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

Использование типов приводит к уменьшению багов

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

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

// Параметры: имя (строка) и возраст (число).
function birthdayGreeting1(...params) {
    return `${params[0]} is ${params[1]}!`;
}

// Параметры: имя (строка) и возраст (число).
function birthdayGreeting2(name, age) {
    return `${name} is ${age}!`;
}

function birthdayGreeting3(name: string, age: number): string {
    return `${name} is ${age}!`;
}

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

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

И во второй, и в третьей функции автор предполагает, что возраст — это число. Поэтому абсолютно приемлемо изменить код так, как показано ниже:

// Параметры: имя (строка) и возраст (число).
function birthdayGreeting2(name, age) {
    return `${name} will turn ${age + 1} next year!`;
}

function birthdayGreeting3(name: string, age: number): string {
    return `${name} will turn ${age + 1} next year!`;
}

Проблема в том, что в некоторых местах, где используется этот код, принимается пользовательский ввод, получаемый из HTML-ввода (то есть всегда строка). И это приведёт к следующему:

> birthdayGreeting2("John", "20")
"John will turn 201 next year!"

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

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

Типы повышают удобство разработки

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

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

Ещё типы существенно упрощают освоение кодовой базы или библиотеки новыми инженерами:

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

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

Давайте рассмотрим следующие изменения в приведённом выше коде:

class Person {
  name: string;
  age: number;
}

function birthdayGreeting2(person) {
    return `${person.name} will turn ${person.age + 1} next year!`;
}

function birthdayGreeting3(person: Person): string {
    return `${person.name} will turn ${person.age + 1} next year!`;
}

function main() {
  const person: Person = { name: "Hello", age: 12 };

  birthdayGreeting2(person);

  birthdayGreeting3(person);
}

Легко увидеть все места (или использовать IDE для их поиска), где применяется Person. Мы видим, что он инициируется в main и используется в birthdayGreeting3. Однако чтобы узнать, что он используется в birthdayGreeting2, придётся прочитать всю кодовую базу.

У этого есть ещё и оборотная сторона: при взгляде на birthdayGreeting2 сложно понять, что она ожидает в качестве параметра Person. Часть таких вопросов можно решить подробной документацией, но: (1) зачем заморачиваться, если можно достичь большего, используя типы? (2) документация устаревает, а тут сам код становится документацией.

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

// a - это person
function birthdayGreeting2(a) {
    b = person.name;
    c = person.age;
    return `${b} will turn ${c + 1} next year!`;
}

Нужно стремиться к использованию полезных имён переменных. Типизация — это тоже самое, она как имена переменных, но гораздо мощнее.

Мы кодируем всё в систему типов

Наша компания любит типы. На самом деле, мы стремимся закодировать в систему типов как можно больше информации, чтобы все ошибки, которые можно отловить во время компиляции, отлавливались во время компиляции; к тому же мы стараемся уместить в неё повышение удобства для разработчика.

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

Рассмотрим следующий фрагмент кода:

pub struct Person {
    pub id: String,
    pub name: String,
    pub age: u16,
}

pub struct Pet {
    pub id: String,
    pub owner: String,
}


let id = "p123";
let person = Person::new("John", 20);
cache.set(format!("person-{id}"), person);
// ...
let pet: Pet = cache.get(format!("preson-{id}"));

В нём есть пара багов:

  1. В имени второго ключа есть опечатка.

  2. Мы пытаемся загрузить person в тип pet.

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

То есть приведённый выше пример будет работать как-то так:

pub struct PersonCacheKey(String);

impl PersonCacheKey {
    fn new(id: &str) -> Self { ... }
}

pub struct Person {
    pub id: String,
        pub name: String,
        pub age: u16,
}

pub struct PetCacheKey;

pub struct Pet {
    pub id: String,
        pub owner: String,
}


let id = "p123";
let person = Person::new(id, "John", 20);
cache.set(PersonCacheKey::new(id), person);
// ...
// В следующей строке компиляция завершится ошибкой
let pet: Pet = cache.get(PersonCacheKey::new(id));

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

Рассмотрим следующую функцию:

pub fn do_something(id: String) {
    let person: Person = cache.get(PersonCacheKey::new(id));
    // ...
}

В ней есть пара проблем. Во-первых, не очень понятно, для какого id она должна использоваться. Это person? Или pet? Очень легко случайно вызвать её с неверным параметром, как в этом примере:

let pet = ...;
do_something(pet.id); // <-- здесь должно быть pet.owner!

Во-вторых, мы теряем понятность. Довольно сложно понять, что Pet имеет взаимосвязь с Person.

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

pub struct PersonId(String);
pub struct PetId(String);

pub struct Person {
    pub id: PersonId,
    pub name: String,
    pub age: u16,
}

pub struct Pet {
    pub id: PetId,
    pub owner: PersonId,
}

И это на самом деле намного лучше, чем предыдущий пример.

Но всё равно остаётся одна проблема. Если мы принимаем id от API, то как нам понять, что они валидны? Например все  id pet в нашей компании имеют префикс pet_, за которым идёт Ksuid:  pet_25SVqQSCVpGZh5SmuV0A7X0E3rw.

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

Поэтому мы принудительно делаем так, чтобы PetId не мог создаваться без предварительной валидации. Благодаря этому мы знаем, что все пути выполнения кода, создающие PetId, сначала проверяют его валидность. Это значит, что когда мы возвращаем пользователю 404 Not Found, потому что pet не найден в базе данных, то можно быть уверенными, что это действительно валидный id, который не найден в базе данных. Если бы это не был валидный id, то мы бы уже вернули 422 или 400 при его передаче обработчикам API.

Так почему же не все любят типы?

Основная аргументация против типов заключается в следующем:

  1. Скорость разработки

  2. Кривая обучения и сложность типов

  3. Объём требуемых усилий и бойлерплейта

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

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

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

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

Кроме того, людям и так нужно учиться писать код, изучать фреймворки (React, Axum и т.д.), а также многое другое. Не думаю, что бремя изучения настолько существенно, как его пытаются представить.

И последнее о кривой обучения: я уверен, что преимущества спрямления кривой обучения благодаря неиспользованию типов гораздо меньше, чем преимущества использования type script для освоения конкретной кодовой базы. И в особенности потому, что изучение типов — это одноразовое вложение труда.

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

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

Да, типизация может быть мучительной в языках, не поддерживающих вывода типов, например, в Java:

Дополнение:  похоже, в Java уже есть вывод типов. Благодарю pron за исправление на HackerNews.

Person person1 = newPerson();
Person person2 = newPerson();
Person child = makeChild(person1, person2);

однако языки с выводом типов (например, Rust) гораздо удобнее:

let person1 = new_person();
let person2 = new_person();
let child = make_child(person1, person2);

То есть наличие подходящих инструментов определённо помогает.

Кстати об инструментах: чтобы пожинать плоды преимуществ типизации, вам, вероятно, нужно использовать редактор кода (или IDE), поддерживающий современное автозавершение кода со знанием языка.

В завершение

Я вижу обе стороны аргументации во многих спорах, например vim против emacs, табы против пробелов, и даже в гораздо более противоречивых. Однако в данном случае затраты настолько малы по сравнению с преимуществами, что я просто не могу понять, почему вообще кто-то может решить не использовать типы.

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

© Habrahabr.ru