Автор: Sergey Teplyakov

Боб не люблю статическую типизацию Мартин произвел отменный вброс в своем недавнем посте Type Wars, в котором он выразил мысль, что наличие TDD и 100%-е покрытие тестами вполне заменяет статическую типизацию:

You don't need static type checking if you have 100% unit test coverage. And, as we have repeatedly seen, unit test coverage close to 100% can, and is, being achieved. What's more, the benefits of that achievement are enormous.

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

Совсем недавно я столкнулся с проблемой, которая проявлялась лишь во время выполнения, но которая могла бы быть решена на уровне системы типов. Вот приблизительный пример: нам нужно написать простую утилиту для логгирования (structured logging), которая бы принимала идентификатор события и определенные данные, связанные с ним. При этом, мы хотим, чтобы идентификатор был числовым и лежал в определенных границах, например, от 0 до 65535.

У нас есть несколько способов сделать это:// Три варианта метода WriteEvent: допустимый диапазон для eventId [0, 65535]
public abstract class EventSource
{
protected abstract void WriteEvent(ushort eventId, params object[] args),
protected abstract void WriteEvent(int eventId, params object[] args),
protected abstract void WriteEvent(object eventId, params object[] args),
}

Данное решение будет строго типизированным, вопрос лишь в том, когда проверка типов будет осуществляться: во время компиляции или во время исполнения. С точки зрения реализации, разницы большой не будет. Мы покроем все тестами, и наши тесты будут падать во втором и третьем случае при передаче аргумента с неправильным значением. Но чем тесты реализации помогут в правильном использовании библиотеки? Правильно, ничем. Тогда, адепт TDD скажет, что перед началом работы с любой библиотекой нам нужно начать с набора тестов, которые покажут, как правильно ею пользоваться. Разумно, хотя далеко не все этому следуют (читай, почти никто так не делает), но остается такой вопрос: а как набор этот набор тестов гарантирует, что все клиенты, вызывающие метод WriteEvent будут передавать корректные данные? Тоже не ясно.

Данный пример навеян реальной проблемой, с которой я столкнулся при работе с ETW (Event Tracing For Windows). Идентификатор события в ETW должен быть от 0 до 65535, но при этом тип идентификатора это int. В результате, когда я случайно стал использовать идентификаторы с большими значениями, то наши тесты начали падать (что хорошо), но я потратил пару часов на разбирательства, поскольку падали они довольно хитрым образом (что совсем не хорошо). После этого, инфраструктурный код был изменен таким образом, чтобы использование невавлидных идентификаторов приводило к ошибке времени компиляции.

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

Три кита корректности

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

  • Система типов
  • Контракты
  • Юнит-тесты

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

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

image

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

Достоинства типов

Помимо корректности, у системы типов есть ряд других важных преимуществ (да, даже у такой слабой, как в C#/Java/C++):

Документирование кода

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

Иногда, можно встретить использование fluent-интерфейсов, которые просто за счет системы типов существенно упрощают использование библиотеки. Многие современные языки (Swift, Go, F#, Eiffel последние версии C# и TypeScript) умеют различать nullable и non-nullable типы, что существенно упрощает понимание семантики метода.

Мне всегда в этом плане импонировал C++, в котором сигнатура метода могла сказать гораздо больше о предполагаемом поведении, чем сигнатура метода в C#/Java: ага, метод константный, значит это запрос, а не команда (помните о Command-Query Separation?). А вот этот метод принимает аргумент по константной ссылке, значит это обязательный входной параметр, а вот это константный указатель, значит это необязательный входной параметр.

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

Улучшенные инструменты

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

Современные среды для JavaScript или Python хачат код и запускают его прямо в IDE, чтобы понять, объекты каких типов может принимать тот или иной метод. Это позволяет реализовать Intellisense, но некоторые вещи, типа Find All References, сделать все равно довольно сложно.

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

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

Производительность

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

В качестве заключения

Мне кажется не честным и не логичным обсуждать виды типизации без надлежащего контекста. Есть сценарии, когда типы не нужны: bash script, веб-клей на JavaScript-е, тысяча строк кода на Python для сбора и анализа данных. Иногда просто нет смысла завязываться на типы, давать им имена и управлять зависимостями явно. (хотя проблема с именами это проблема не статической типизации, а номинальной системы типов, статическая структурная типизация как в языках Go и TypeScript позволяет объявить метод, который принимает что-то с полями x и y, при этом имя этого типа может быть любым).

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

Тесты полезны, но они находятся слишком далеко, их не всегда легко найти для этого метода и из них не всегда легко понять, какой же у него контракт. К тому же, тесты это тоже код, который нужно поддерживать, а это значит, что если система типов позволит уменьшить количество тестов даже на 10%, то это существенно отразится на сопровождаемости системы.

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

Дополнительные ссылки

  • Type Wars by Robert Martin
  • DynamicTyping by Martin Fowler
  • Types vs Tests: An Epic Battle?
    Очень интересное видео, рекомендую к просмотру. Там интересный момент, что спикер совсем не упомянул контракты, но Мартин СкалА Одерски напомнил, что контракты являются тем самым способом выражения спецификации в коде, когда система типов с этим уже не справится.

Помогла статья? Оцените её!
0 из 5. Общее количество голосов - 0
 

You have no rights to post comments

Дмитрий Крикунов

Публикую статьи, обучающие курсы и новости по программированию: алгоритмам, языкам (С++, Java), параллельному программированию, паттернам и библиотекам (Qt, boost).