Многие языки программирования включают в себя избыточные возможности. Развитие языка включает в себя работу по их исключению.
Существует много языков программирования, и новые продолжают появляться всё время. Лучше ли они тех, что уже существовали раньше? Очевидно, что на этот вопрос невозможно ответить, пока не будет дано чёткое определение что же такое «лучше» в отношении языков программирования.
Если вы посмотрите на исторические тренды, то заметите один из путей сделать лучший язык программирования — определить какую-нибудь избыточную возможность в уже существующем языке и спроектировать новый язык без неё.
«Совершенство достигается не тогда, когда нечего добавить, а тогда, когда нечего убрать»
Антуан де Сент-Экзюпери
В этой статье вы увидите несколько примеров возможностей различных языков программирования, которые уже общепризнанны избыточными и ещё несколько других, которые имеют те же черты и могут когда-нибудь быть отнесены к той же группе.
Безграничное количество способов выстрелить себе в ногу
Когда были созданы первые компьютеры, программы для них должны были писаться в машинных кодах или на языке ассембера. В машинных кодах вы можете выразить вообще совершенно всё, что способен выполнить процессор, поскольку машинные коды строго соответствуют набору инструкций процессора. В машинных кодах можно писать корректные программы, но также можно написать бесконечное количество некорректных программ, включающих те, что будут зависать, падать, или некорректно использовать аппаратное обеспечению, на котором они работают.
Сегодня вы, скорее всего, пишите код на каком-нибудь высокоуровневом языке программирования, но даже с ним, согласитесь, не так просто заставить вещи выполняться без ошибок. На каждую корректную программу приходится множество её некорректных вариантов.
С использованием машинных кодов у вас есть неограниченное число способов создать уникальную некорректную программу. Вы ведь можете выполнить любую команду процессора в любой момент. Набор корректных последовательностей команд — лишь малое подмножество набора всех возможных последовательностей инструкций.
Первые программисты быстро обнаружили, что написание программ в машинных кодах весьмо подвержено ошибкам, а кроме того код получается длинные и плохо читаемый. Одним из способов решения проблемы стало создания языка ассемблера. Но на самом деле это мало в чём помогло: программы на ассемблере были чуть более человекочитаемы, но они всего лишь один к одному повторяли машинные инструкции, а значит набор потенциально некорректных программ, которые можно было написать, оставался тем же.
Высокоуровневые языки
После некоторого периода мучения с машинными кодами и ассемблером программисты придумали высокоуровневые языки программирования. Первые такие языки не очень применяются сегодня, но одним из примеров этих «низкоуровневых» высокоуровневых языков, который до сих пор в ходу, является язык С.
На языке С вы можете написать практически любую валидну программу. Всё ещё есть вагон и маленькая тележка написать некорректную программу, способную, например, упасть. Но, поскольку язык представляет собой некоторую абстракцию над машинным кодом — есть и некоторые наборы машинных команд, которые мы не можем выразить на таком языке. Большинство из них представляют собой невалидные программы, а значит абстракция, отсекающая их — приносит пользу.
Обратите внимание, что произошло: переход от машинного кода к высокоуровневым языкам убрал некоторые возможности. В машинных кодах можно было написать что-угодно, на высокоуровневом языке написать можно уже не всё. Мы миримся с этим, поскольку плюсы подхода перевешивают его минусы.
GOTO
В 1968-ом году Эдсгар Дейкстра опубликовал свою знаменитую работу о вреде оператора goto. В ней он высказал мысль, что сама концепция использования оператора goto порочна, а программы были бы значительно лучше без goto. Это породило спор, растянувшийся на десятилетия, но сегодня мы пришли к пониманию того, что Дейкстра был прав. Многи популярные языки программирования сегодня не имеют оператора goto (например, Java или Javascript).
Поскольку goto оказался абсолютно ненужным оператором, то его выбрасывание из языка не уменьшает количество валидных программ, которые вы можете написать на этом языке. Но оно уменьшает количество невалидных
Вы уловили паттерн? Уберите что-то ненужное — и вы получаете усовершенствование. В этом нет ничего нового — Robert C. Martin говорил об этом многие годы назад.
Вы не можете убирать из языка просто первые попавшиеся функции — в этом случае вы можете потерять возможность написать некоторые валидные программы.
Но вы можете убирать некоторые функции без вреда для языка.
Исключения
Сегодня все соглашаются с тем, что ошибки должны обрабатываться некоторым механизмом обработки исключительных ситуаций. По крайней мене, мы соглашаемся с тем, что одних лишь кодов ошибок не достаточно. Нам нужно что-то посерьёзнее, но в тоже время сохраняющее балланс полезности и производительности.
Проблема с исключениями в наших языках программирования — это то, что они по сути являются замаскированным GOTO. А мы уже выяснили, что использование GOTO — это плохо.
Лучшим подходом может быть использование некоторого композитного типа, собирающего в себе информацию об успехе выполнения некоторого блока кода, или ошибках в нём.
Указатели
Как отметил Robert C. Martin, в старых языках программирования вроде С и С++ вы можете манипулировать указателями, но как только вы вводите понятия полиморфизма — чистые указатели вам больше не нужны. В Java нет указателей, в Javascript нет указателей. В С# есть указатели, но нужны они лишь в редки случаях, вроде прямого вызова WinApi-функций.
Все эти языки доказали, что вам не нужны указатели для того, чтобы передать что-нибудь куда-нибудь по ссылке. От указателей можно избавиться.
Числовые типы
Большинство строго типизированных языков программирования дают вам возможность выбрать между несколькими числовыми типами: 16-битное целое число, 32-битное целое, 32-битное беззнаковое целое, дробное с плавающей точкой, и т.д. Это имело смысл в 1950-ых, но редко бывает важно сегодня. Мы тратим много времени на микро-оптимизации, вроде выбора правильного числового типа, теряя общую картину. Как сказал Дуглас Крокфорд: «В Javascript есть только один числовой тип, что является отличной идеей. Жаль только, что это неправильный числовой тип».
С ресурсами современного компьютера мы можем позволить себе язык программирования, дающий ровно один числовой тип. Такой язык будет отличным средством выбросить из головы весь этот хаос, связанный с многообразием числовых типов.
Нулевые указатели
Нулевые указатели — одна из наиболее недопонятых концепций в языках программирования. Нет ничего плохого в том, что некоторое значение может быть установлено, а может отсутствовать. Такая концепция есть во многих языках программирования. В Хаскеле она называется Maybe, в F# это option, в T-SQL это null. Что общего во всех этих языках — это то, что данная возможность опциональна. Вы можете объявить значение как «nullable», но по умолчанию оно таковым не является.
Однако, из за ошибки Тони Хоара, которую он сам признал и оценил в миллиард долларов, во многих языках есть нулевые указатели: C, C++, Java, C#. Проблема не в концепции «нулевого указателя», а в том, что любой указатель по умолчанию может быть нулевым, что делает невозможным сделать различие между случаями, в которых null — ожидаемое и валидное значение указателя и теми ситуациями, когда это дефект.
Создайте языке без нулевых указателей и вам никогда не придётся задумываться о генерации или обработке ошибок доступа по нулевому указателю.
Если Тони Хоар прав в своей оценки ущерба от этого бага в миллиард долларов, то избавление от нулевого указателя прямо сейчас может сохранить нам всем много денег и в будущем. Из-за существования полных по Тьюрингу языков программирования без нулевых указателей (вроде упомянутых T-SQL, Хаскеля и F#) мы знаем, что можем выразить любую валидную программу без данной концепции, а значит её устранение уберёт огромный пласт ошибок.
Изменение значений переменных
Одной из центральных концепций в процедурном, императивном и объектно-ориентированном стиле является то, что вы можете изменить значение переменной по ходу работы вашей программы. Это и есть причина, почему это называется «переменной». Это кажется логичным и интуитивно правильным, ведь процессор содержит регистры, а всё, что вы делаете по ходу программы — так это записываете туда данные, выполняете команды и считываете результаты. Это логично и с другой стороны: большинство программы предназначены для того, чтобы как-то изменить состояние внешнего мира — записать данные в базу, отослать email, вывести картинку на экран, распечатать документ и т.д.
Однако, оказалось что изменение значений переменных — огромный источник ошибок в программном обеспечении. Представьте, например, вот такую строчку кода на С#:
var r = this.mapper.Map(rendition);
Когда метод Map возвращает значение — был ли параметр rendition модифицирован? Если вы следуете некоторым принципам — он не должен бы измениться. Но единственный способ в этом убедиться — пересмотреть код метода Map. А что, если он передаёт это значение дальше? Вдруг какой-то из них изменит его? И будет делать это лишь в некоторых случаях. Отладка сильно усложниться. В языке С# (да и в Java, и в Javascript) нет ничего для предотвращения этой проблемы.
В сложной программе с длинным стеком вызовов вообще невозможно сказать что-то определённое о коде, поскольку в любом методе может измениться что-угодно, а как только у вас будут десяткисотни переменных в проекте — вы больше не сможете держать их состояние в голове. «Флаг isDirty менялся? Где? А это как-то связано с customerStatus?».
Давайте представим себе, что возможность изменять переменные была убрана из языка программирования:
Большинство языков программирования не идут на такой радикальных шаг, но Хаскель (при своей полное по Тьюрингу) демонстрирует, что вполне возможно написать любую программу без явного изменения значений переменных.
В этом месте многие люди могут возразить, что Хаскель слижком сложен и неинтуитивен, но для меня эти аргументы похожи на аргументацию ортодоксов по поводу защиты оператора goto. Если вы долго полагались на goto, то вам рано или поздно придётся выучить новые способы выражать код без его помощи. Точно так же, если вы привыкли полагаться на концепцию изменяемых переменных, вам придётся научиться моделировать то же самое поведение без них.
Сравнение ссылок
В объектно-ориентированных языка вроде C# или Java операцией по-умолчанию для ссылочных типов является сравнение ссылок. Если две переменных указывают на один и тот же адрес памяти — они считаются равными. Если две переменных указывают на два разных блока памяти (пускай даже заполненных идентичными данными) — они не считаются равными. Это не интуитивно и порождает баги.
Что, если убрать из языка сравнение ссылок?
Брать и сравнивать объекты по их содержимому.
Я не уверен до конца в этой идее, но по моему опыту, когда вы что-то с чем-то сравниваете, то хотите знать равно ли содержание, а не равна ли ссылка. Возможно, сравнение ссылок понадобится для какой-то редкой оптимизации — на этот случай мы можем сохранить функцию сравнения ссылок где-нибудь в стандартной библиотеке. Но не как поведение по умолчанию.
Наследование
Даже сегодня наследование встречается нам везде. Уже 20 лет назад Банда Четырёх советовала нам предпочитать композицию наследованию. Нет ничего такого, что вы можете сделать с помощью наследования и не можете сделать с помощью композиции и интерфейсов. Обратное утверждение неверно для языков с одиночным наследованием: есть вещи, которые вы можете сделать с помощью интерфейсов (вроде реализовать более одного), но не можете сделать с помощью наследования. Композиция — это надмножество наследования.
Это не просто теория. Мне уже многие годы удаётся писать код, избегая наследования. Как только вы набьёте руку в этом деле, это станет делать легко и привычно.
Интерфейсы
Многие строго типизированные языки (например, Java и С#) имеют интерфейсы, которые являются там механизмом реализации полиморфизма. Таким образом вы можете объеденить различные операции вместе, как методы интерфейса. Однако, одно из последствий применения SOLID состоит в том, что вы должны предпочитать интерфейсы, обозначающие какую-то одну роль, а не интерфейсы, включающие наборы методов. Логический вывод из этого — каждый интерфейс должен иметь ровно один метод. В этом случае само название и определение интерфейса становиться уже излишним — нас интересует только описываемая им операция, её параметры и результат. А для выражения этого у нас есть другие средства — делегаты в С#, лямбды в Java.
В этом нет ничего нового или страшного. Функциональные языки использовали функцию как базовую единицу композиции многие годы.
Согласно моему опыту, вы можете смоделировать всё, что угодно с помощью интерфейсов с одним методом, что автоматически означает возможность смоделировать то же самое и без самих интерфейсов. Повторюсь, здесь нет никаких «поразительных открытий», функциональные языки всё так и делают — и остаются полны по Тьюрингу.
Отражение
Если вы когда-нибудь занимались мета-программирование на .NET или Java, вы, скорее всего, знаете, что такое отражение. Это набор API и возможностей языка программирования или платформы, позволяющий вам извлекать информацию о коде и выполнять его.
Мета-программирование — незаменимый инструмент, так что жаль будет его терять. Однако, отражение — не единственный способ воспользоваться мета-программированием. Некоторые языки программирования гомоиконны, а значит программы на этих языках представляют собой структурированные данные, которые сами по себе могут служить источником информации или объектом манипуляций. Подобные языки не требует отражения как отдельной языковой возможности, поскольку мета-программирование уже зашито в язык другим способом.
Другими словами: отражение существует лишь как средство для мета-программирования. Если мета-программирование можно будет применять через гомоиконность, это сделает отражение ненужной возможностью языка.
Циклические зависимости
Хотя нулевые указатели и являются наибольшим источником проблем в коде, есть ещё одна проблема, приводящая к аналогичным масштабам проблем в плане поддержки разрастающейся кодовой базы — связанность. Один из аспектов проблем со связанностью — циклические зависимости. В языках вроде C# или Java циклических зависимостей невозможно избежать встроенными средствами языка.
Вот одна из моих ошибок, которую я нашел лишь потому, что начал целенаправленно её искать: в одном на первый взгляд неплохом модуле AtomEventStore был интерфейс IXmlWritable:
public interface IXmlWritable
{
void WriteTo(XmlWriter xmlWriter, IContentSerializer serializer);
}
Метод WriteTo принимает аргументом IContentSerializer, который объявлен вот так:
public interface IContentSerializer
{
void Serialize(XmlWriter xmlWriter, object value);
XmlAtomContent Deserialize(XmlReader xmlReader);
}
Обратите внимание, что Deserialize() возвращает XmlAtomContent. Как же определён XmlAtomContent? А вот так:
public class XmlAtomContent : IXmlWritable
Смотрите, он реализует IXmlWritable — и вот она, циклическая зависимость, которая требует выражения IXmlWritable через самого себя!
Я постоянно проверяю код на подобные вещи, но вот эта ошибка сумела прокрасться мимо меня. В языке F# (и, по-моему, в OCaml) такой код даже не скомпилировался бы! Хотя F# и позволяет вводить небольшие циклические зависимости на уровне модулей с помощью ключевых слов and и rec, у вас не может образоваться циклической зависимости случайно. Вам нужно явно выразить своё желание на создание такой связи — и даже в этом случае вы не можете пересечь границу одного модуля или библиотеки.
Какая отличная защита от плотно связанного кода! Уберите возможность случайного создания циклических зависимостей — и получите лучший язык.
Данный вопрос даже исследовался «в поле»: Scott Wlaschin анализировал реальные проекты на C# и F# и обнаружил, что проекты на F# имеют меньше циклических зависимостей, чем проекты на C#. Это исследование позже продолжила в своей работе Evelina Gabasova.
Выводы
В данной статье я попытался показать, как можно сделать язык программирования лучше, убрав из него некоторую функциональность. Уберите избыточную фичу — и вы всё ещё останетесь с полным по Тьюрингу языком программирования, способным вызать всё, что угодно (ну, почти всё), но имеющим меньше возможностей выстрелить себе в ногу.
Возможно, идеальный язык программирования, это язык без:
- GOTO
- Исключения
- Указатели
- Множественные числовые типы
- Нулевые указатели
- Изменение значений переменных
- Сравнение ссылок
- Наследование
- Интерфейсы
- Отражение
- Циклические зависимости
Я перечислил все возможные избыточные возможности? Скорее всего нет, а значит у разработчиков новых языков есть отличная возможность придумать ещё лучший язык, убрав из него что-нибудь ещё!
Автор: Инфопульс Украина