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

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

Я потратил более 20 лет на написание кода на статически типизированных языках, поэтому я хорошо осведомлен о преимуществах. Тем не менее, я предпочитаю динамические языки. Чтобы быть ясным, я не решаю исключительно, какой язык мне нравится, на основе динамического или статического. Мои любимые языки, наверное, Julia, Go, Swift и LISP. Таким образом, в группу входят языки с динамической и статической типизацией.

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

Корректность и поиск ошибок

Что является точным смыслом работы со статически типизированными языками, мы не хотим сюрпризов в производственном коде во время выполнения.

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

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

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

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

Сила системы типов имеет значение даже во время выполнения

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

For-loop, while-loop и if-statement как в C, C ++, так и в Objective-C с радостью принимают небулевы выражения в условном выражении. Это может вызвать затруднения для отслеживания ошибок. Рассмотрим этот надуманный пример кода для умножения двух чисел с помощью цикла while. Разработчик забыл поместить i < count в цикл while. Нет сообщения об ошибке, просто бесконечный цикл зависания программы.

#include <stdio.h>
void multiply(int count, int input) {
   int result = 0;
   int i = 1;
   while (count) {
       result += input;
       i += 1;
   }
   printf("%d times %d equals %d", input, count, result);    
}
int main(int argc, char **argv) {
   multiply(9, 8);
}

Вот пример того же самого в Юлии:

using Printf
function multiply(count, input)
   result = 0
   i = 1
   while count
       result += input
       i += 1
   end
   @printf("%d times %d equals %d", input, count, result);
end
multiply(9, 8)

Я поместил это в файл с именем boolean.jl. Если я попытаюсь выполнить этот код, я получу:

ERROR: LoadError: TypeError: non-boolean (Int64) used in boolean context
Stacktrace:
 [1] multiply(::Int64, ::Int64) at boolean.jl:6
 [2] top-level scope at none:0
 [3] include at ./boot.jl:326 [inlined]
 [4] include_relative(::Module, ::String) at ./loading.jl:1038
 [5] include(::Module, ::String) at ./sysimg.jl:29
 [6] exec_options(::Base.JLOptions) at ./client.jl:267
 [7] _start() at ./client.jl:436
in boolean.jl:13

Это означает, что я получаю четкое сообщение об ошибке, сообщающее мне, что пошло не так TypeError: non-boolean (Int64) used in boolean context И оно сообщает мне, в какой функции, в каком файле в каком номере строки [1] multiply(::Int64, ::Int64) at boolean.jl:6 произошла эта ошибка. Наконец, он сообщает мне, что ошибка была вызвана звонком от [7] _start() at ./client.jl:436 in boolean.jl:13 . При использовании консоли iTerm я даже могу щелкнуть по команде путь и номер строки, чтобы перейти прямо к файлу и исправить ошибку.

Если бы это был оператор if, похороненный в большой кодовой базе, его было бы трудно обнаружить. Мой аргумент в основном таков:

Сильная система динамического типа превосходит более слабую систему статического типа

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

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

Я буду использовать C / C ++ в качестве примера, потому что это отличный пример статической типизации, не защищающей вас от многих вещей, от которых на самом деле вас защищают динамические языки. Рассмотрим широкий спектр ошибок памяти, которые допускает C ++:

  • Нулевые указатели, которые вы забыли проверить
  • Неправильный порядок статической инициализации и деинициализации.
  • Использование висячих указателей
  • Использование неинициализированных объектов.
  • Конструкторы сломанных копий. Освобождение памяти, используемой другими объектами.

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

Возьмем, к примеру, Джулию, поскольку именно это я использовал в своей статье и обычно пишу. Мне не нужно писать код, чтобы проверять, являются ли аргументы моей функции нулевыми, потому что, если я специально не разрешил это, функция будет никогда не получить нуль в одном из своих аргументов. Это проблема программирования на C / C ++, Java и C #. Вы должны добавить много проверок, чтобы убедиться, что вы не получили нулевой указатель. Ваша программа проверки типов во время компиляции этого не обнаруживает.

Целочисленные переполнения - это не то, что система типов может уловить, но такие языки, как Python, обрабатывают это совершенно прозрачно.

Способность изящно обрабатывать ошибки в динамических языках

Еще один аспект динамических языков, о котором часто забывают, - это способность корректно обрабатывать ошибки. Одним из примеров являются коммутаторы телефонной сети. Если бы статически типизированные языки были такими удивительными, создавали бы правильные программы и избегали бы ошибок, они бы преобладали над переключателями. И все же самым известным языком программирования для программирования надежных и отказоустойчивых сетевых коммутаторов является Erlang, динамический язык. Это работает, потому что Erlang моделирует систему с сотнями отдельных процессов, которые контролируют друг друга. Один процесс выходит из строя, его можно перезапустить с помощью процесса мониторинга. Как говорит Джо Армстронг, создатель Erlang:

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

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

Около половины мировых сетей мобильной связи написаны на Erlang. WhatsApp написан на Erlang и обрабатывает 65 миллиардов сообщений в день. Это вещи, которые должны быть надежными и иметь высокое время безотказной работы, и они работают на динамическом языке.

Вот еще один интересный случай: миссия НАСА 1998 Deep Space 1 запускала LISP.

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

Так что это не было проблемой, связанной с типом. Статическая проверка типов не решила бы проблему. Однако, поскольку он запускал динамический язык программирования с доступом к среде REPL, команда NASA смогла подключиться к нему, диагностировать проблему и изменить код в реальном времени. Это было бы очень сложно сделать на языке со статической типизацией.

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

Время туда и обратно и производительность

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

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

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

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

Это осознают разработчики программного обеспечения, поэтому уровни в компьютерных играх и поведение персонажей почти исключительно написаны на динамических языках. Сам игровой движок часто написан на языке со статической типизацией, таком как C ++. Но это работает, потому что довольно ясно, что он должен делать:

  • Быстрая визуализация большого количества графики
  • Быстрое обнаружение столкновений между несколькими объектами

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

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

В науке о данных и научных вычислениях в целом преобладают динамические языки, такие как Julia, Python, R и Matlab. Это во многом по той же причине, что и динамические языки, доминирующие в разработке игр (часть игрового процесса). Вы часто занимаетесь исследовательским кодированием, экспериментируете с данными.

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

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

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

Тесты против проверки типа во время компиляции

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

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

Мета-программирование и производительность

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

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

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

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

Конечно, количество строк кода бесполезно, если вы не можете прочитать код, однако это идет в обоих направлениях. Очень сложный код может быть трудно расшифровать, но также может быть и очень подробный код. Например, код шаблона C ++, как известно, трудно читать.

Когда использовать статически и динамически типизированные языки

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

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

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

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

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

Личные причины предпочтения динамических языков

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

Простые системы легче рассуждать и, следовательно, правильно строить и обнаруживать проблемы. Моя проблема со статически типизированными языками заключается в том, что почти все они становятся слишком сложными. C ++ - особенно гротескный пример.

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

Если бы это был только вопрос языка, у нас все было бы хорошо. Однако эти языки также имеют тенденцию поставляться с раздутыми IDE, такими как Visual Studio, Eclipse и т. Д., Которые требуют значительных затрат времени на освоение. Кроме того, они часто требуют владения сложными системами сборки. Это быстро складывается из множества движущихся частей, которые нужно держать в голове одновременно.

Я предпочитаю языки, на которых можно писать с помощью мощного редактора кода. Теперь вы можете потратить на них много времени, но это в значительной степени необязательно, и они являются общими инструментами. Навыки, которые вы развиваете с помощью продвинутого текстового редактора, можно применить практически к любой работе. Я могу использовать тот же инструмент для написания Markdown, XML, Julia, Go, сценариев оболочки, файлов конфигурации, файлов CSV и т. Д. И они сосредоточены на одной четкой задаче: эффективно управлять текстом, что бы этот текст ни представлял.

Комбинируя редактор с терминалом и динамическим языком, вы получаете мощную комбинацию. Многие инструменты, которые есть у пользователей статических языков в своей среде IDE, я просто добавляю как обычный код. Подсветка синтаксиса в REPL - это просто пакет Julia, загруженный в REPL. То же самое с отладчиком и менеджером пакетов.

Одним из интересных примеров в этом отношении является среда программирования smalltalk. Это динамический язык, и вся Smalltalk IDE написана на Smalltalk. При написании кода smalltalk вы выбираете между написанием кода для вашего проекта и кода для расширения и изменения IDE для облегчения выполняемой вами работы. Существует сильный симбиоз, который было бы трудно реализовать, - это язык со статической типизацией.

Идея работы разделена на 3 отдельных этапа:

  • написание кода
  • составление
  • Бег

Имеет смысл для статически типизированных языков, но часто является искусственным разделением для пользователей динамических языков. В частности, в среде Smalltalk код работает почти постоянно. Код работает, пока вы пишете код. Если ваш код не работает, вы быстро получите отзыв об этом.

Таким образом, идея убедиться, что ваш код верен, прежде чем запускаться в «живую», является искусственной, поскольку во многих динамических языках, в частности в Smalltalk и LISP, вы постоянно живете. Система, которую вы создаете, часто работает, пока вы ее строите. Весь процесс намного более органичен, и это трудно передать давним разработчикам на статически типизированных языках.