Статьи

Парадокс о безопасности и выразительности

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

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

В двух словах, динамическая типизация менее выразительна, чем статическая, поскольку она не полностью выражает типы . Конечно, это довольно глупая тавтология. Но это все еще стоит сказать. Почему? Потому что типы имеют значение. Даже в динамически типизированном языке, таком как Python, Smalltalk или Ruby, типы по-прежнему имеют значение. Чтобы понять и поддерживать код, мне все еще нужно знать типы вещей . В самом деле, это верно даже для слаботипированного JavaScript!

Следствием этого является то, что динамически типизированный код гораздо менее самодокументируется, чем статически типизированный код. Быстро, что я могу перейти к следующей функции? Что я от этого получу?

1
function split(string, separators)

Эта функция подписи, переведенная на Цейлон, сразу стала более понятной:

1
{String*} split(String string, {Character+} separators)

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

С другой стороны, статическая типизация вынуждает меня поддерживать правильные аннотации типов в функции split() , даже когда ее реализация меняется, даже когда я спешу, и моя IDE даже поможет мне автоматически реорганизовать их. Ни одна IDE на Земле не предлагает такую ​​же помощь, поддерживая комментарии!

Теперь подумайте, что еще эта дополнительная выразительность покупает мне. Предположим, что у Foo и Bar нет общего интересного супертипа. На любом динамическом языке я мог бы написать следующий код:

1
2
3
4
5
6
7
class Super {
    function fun() { return Foo(); }
}
  
class Sub extends Super {
    function fun() { return Bar(); }
}

Но если бы я написал такую ​​вещь, мои товарищи по команде, вероятно, захотели бы меня задушить! Здесь просто слишком много возможностей для клиента, вызывающего fun() чтобы обрабатывать только случай Foo и не понимать, что иногда он вместо этого возвращает Bar . Как правило, большинство программистов избегают кода, подобного описанному выше, и следят за тем, чтобы fun() всегда возвращала типы, тесно связанные наследованием.

В качестве второго примера рассмотрим известную дыру в системе типов Java: null . На Java я мог бы написать:

1
2
3
4
5
6
7
class Super {
    public Foo fun() { return Foo(); }
}
  
class Sub extends Super {
    public Foo fun() { return null; }
}

Опять же, этого часто избегают разработчики Java, особенно для общедоступных API. Поскольку подпись fun() не является частью того, что он может возвращать null , и поэтому вызывающая сторона может просто не обращать внимания на этот случай, что приводит к NPE где-то еще дальше, часто рекомендуется использовать исключение вместо возвращает значение null из метода, принадлежащего общедоступному API.

Теперь давайте рассмотрим Цейлон.

1
2
3
4
5
6
7
class Super() {
    shared default Foo? fun() => Foo();
}
  
class Sub() extends Super() {
    shared actual Null fun() => null;
}

Поскольку тот факт, что fun() может вернуть значение null является частью его сигнатуры, и поскольку система типов заставляет любого вызывающего пользователя учитывать возможность возврата значения NULL, в этом коде нет ничего плохого. Поэтому на Цейлоне часто лучше определять функции или методы, которые просто возвращают null а не генерируют защитное исключение. Таким образом, мы приходим к очевидному парадоксу:

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

Теперь, поскольку «обнуляемый тип» на Цейлоне — это просто особый случай типа объединения, мы можем обобщить это наблюдение для других типов объединения. Рассматривать:

1
2
3
4
5
6
7
class Super() {
    shared default Foo|Bar fun() => Foo();
}
  
class Sub() extends Super() {
    shared actual Bar fun() => Bar();
}

Опять же, в этом коде нет ничего плохого. Любой клиент, вызывающий Super.fun() , вынужден обрабатывать оба возможных конкретных типа возврата.

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