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

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

Для меня проблема заключается в том, что оба языка удвоили сильную статическую типизацию. Оба, кажется, намерены закрыть каждую брешь в типах своих родительских языков. У Swift родительским языком является причудливый безтиповой гибрид C и Smalltalk под названием Objective-C, поэтому, возможно, акцент на типизации понятен. В случае Kotlin родительским языком является уже достаточно сильно типизированная Java.

Не хочу, чтобы вы подумали, что я выступаю против статически типизированных языков. Это не так. Есть определенные преимущества и у динамических, и у статических языков; и я с удовольствием использую оба вида. Я немного предпочитаю динамическую типизацию, и поэтому  довольно часто использую Clojure. С другой стороны, я, вероятно, пишу больше на Java, чем на Clojure. Так что считайте меня битипичным. И хожу по обеим сторонам улицы — можно так сказать.

Меня беспокоит не тот факт, что Swift и Kotlin статически типизированы. Скорее, дело в глубине этой статической типизации.

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

Swift и Kotlin, с другой стороны, совершенно негибкие, когда речь идет о правилах типов. Например, в Swift, если вы объявляете функцию, которая будет выбрасывать exception, то, ей-богу, каждый вызов этой функции, вплоть до стека, должен быть снабжен блоком do-try, try! или try?. В этом языке нет способа тихо выбросить исключение на самом верхнем уровне, не прокладывая для него супер-путь по всему дереву вызовов (вы можете посмотреть, как мы с Джастином боремся с этим в наших видеороликах с примерами мобильных приложений).

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

Вопрос в том, в чьи обязанности входит управление этим риском? Это работа языка? Или это работа программиста?

В Kotlin вы не можете получить производное от класса или переопределить функцию, если вы не украсите этот класс или функцию как open. Вы также не можете переопределить функцию, если она не декорирована как override. Если вы не добавите к классу open, язык не позволит вам получить от него производную.

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

И Swift, и Kotlin включили в себя концепцию nullable (обнуляемых) типов. Тот факт, что переменная может содержать null, становится частью типа этой переменной. Переменная типа String не может содержать null; она может содержать только реифицированную String. С другой стороны, переменная типа String? имеет тип nullable и может содержать null.

Правила языка требуют, чтобы при использовании nullable переменной вы должны сначала проверить ее на null. Поэтому если s - это String?, то var l = s.length() не скомпилируется. Вместо этого вы должны сказать var l = s.length() ?: 0 или var l = if (s!=null) s.length() else 0.

Возможно, вы думаете, что это хорошо. Возможно, вы видели достаточно исключений NPE (NullPointerException) на своем веку. Возможно, вы знаете, без тени сомнения, что непроверенные null являются причиной миллиардов и миллиардов долларов неудач в программном обеспечении. (Действительно, в документации по Kotlin NPE назван "ошибкой на миллиард долларов"). И, конечно, вы правы. Очень рискованно, когда null бесконтрольно разгуливают по системе.

Вопрос в том, кто должен управлять null. Язык? Или программист?

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

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

Это неправильный путь!

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

Теперь спросите себя, почему эти дефекты случаются слишком часто. Если вы ответите, что наши языки не предотвращают их, тогда я настоятельно рекомендую вам бросить работу и никогда больше не думать о том, чтобы стать программистом; потому что дефекты никогда не являются следствием несовершенства наших языков. Виноваты программисты. Именно программисты создают проблемы — не языки.

И что же программисты должны делать, чтобы предотвратить дефекты? Попробую предположить. Вот несколько подсказок. Это глагол. Он начинается с буквы "Т". Да. Вы угадали. Тестировать!

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

Почему в языках применяются все эти функции? Потому что программисты не тестируют свой код. А поскольку программисты не тестируют свой код, у нас теперь есть языки, заставляющие добавлять слово open перед каждым классом, от которого мы хотим получить производную. У нас есть языки, которые заставляют декорировать каждую функцию, вплоть до дерева вызовов, дополнительной конструкцией try! Теперь у нас есть языки, которые настолько ограничены и переопределены, что вам нужно заранее спроектировать всю систему, прежде чем вы сможете написать какой-нибудь код для любой из ее частей.

Подумайте: Как узнать, класс open или нет? Как определить, что где-то внизу дерева вызовов может выскочить исключение? Сколько кода мне придется изменить, когда я наконец обнаружу, что кому-то действительно нужно вернуть null в дереве вызовов?

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

И из-за всех этих презумпций они подвергают наказанию, как только вы ошибаетесь. Они заставляют вернуться назад и изменить огромное количество кода, добавляя try! ,  ?: или open по всему стеку.

А как избежать наказания? Есть два способа. Один работает, а другой нет. Тот, который не работает, заключается в том, чтобы разрабатывать все заранее, до начала кодирования. Тот, который позволяет избежать наказания, — это переопределить все меры безопасности.

Поэтому вы объявите все свои классы и все свои функции open. Вы никогда не будете использовать исключения. И вы привыкнете использовать много-много символов !, чтобы отменить проверку null и позволить NPE бесчинствовать в ваших системах.

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


Сегодня в 20:00 состоится открытое занятие по теме «Понятие "состояние" в шаблонах проектирования». На это уроке рассмотрим понятие «состояние». Посмотрим, как работать с диаграммой состояний и переходов. Проведем обзор конечных автоматов и поймем, как от простой реализации объектов перейти к интерфейсам. Регистрация здесь.

Комментарии (3)


  1. agalakhov
    18.05.2022 14:41
    +11

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


    Строгость языка, контроль нужен не столько программисту, сколько самим разработчикам компилятора. Точнее, его оптимизатора. В языках вроде C++ компилятору приходится гадать, что имел в виду программист, отсюда костыли вроде strict aliasing. Возможность разыменовать null-тип, то есть сконструировать bottom-тип, формально равноценна возможности выполнить бесконечный цикл, а потом работать дальше, так как bottom-тип — именно то, что формально возвращает бесконечный цикл. Пытаясь сделать оптимизирующий компилятор для таких вещей, есть неиллюзорный риск создать компилятор, который неверно компилирует верный код — худший кошмар программиста из всех возможных. Чем ближе язык к строгой математике, тем легче его компилировать. Бонусом получается защита программиста от ошибок на compile-time.


  1. SadOcean
    18.05.2022 15:41
    +3

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


  1. lgorSL
    18.05.2022 16:12
    +9

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

    > Например, в Swift, если вы объявляете функцию, которая будет выбрасывать exception, то, ей-богу, каждый вызов этой функции, вплоть до стека, должен быть снабжен блоком do-try, try! или try?.

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

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

    Да. На практике получается, что в нормальном коде большая часть переменных и аргументов - не nullable. А если они nullable, то их, само собой, надо проверять на null. И язык об этом напомнит.

    > Поэтому вы объявите все свои классы и все свои функции
    open. Вы никогда не будете использовать исключения. И вы привыкнете
    использовать много-много символов !, чтобы отменить проверку null и
    позволить NPE бесчинствовать в ваших системах.

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