Как извлечь пользу из статической типизации

    Живые данные ограниченные типами перетекают из состояние в состояние
    Живые данные ограниченные типами перетекают из состояние в состояние

    Эта статья о том, как извлечь максимум пользы из статической системы типов при дизайне вашего кода. Статья пытается быть language agnostic (получается не всегда), примеры на Java и взяты из жизни. Хотя академические языки вроде Idris позволяют делать больше полезных трюков со статической типизацией, а полный вывод типов существенно сокращает размер церемоний, на работе мы пишем на языках другого типа, а хорошие знания хочется уметь применять на практике, так как это сделает нашу жизнь лучше уже сегодня.

    Краткий пересказ сюжета статьи

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

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

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

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

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

    То есть во время дизайна вам стоит взглянуть на те свойства вашей программы, которые есть у её текста, но которых нет у тех инструкций, которые в конце концов будут исполнены.

    План у нас следующий:

    1. Сперва рассмотрим минусы статической типизации перед динамической: обозначим проблемы, которые хочется решить.

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

    3. Затем мы подробно поговорим об основных видах полиморфизма. Полиморфизм в широком смысле — это основной инструмент, с помощью которого мы будем решать проблемы. Глубокое понимание полиморфного кода — ядро всей статьи.

    4. Наконец, мы рассмотрим ряд примеров решения описанных проблем.

    5. Пара слов о том, как абстрагирование уменьшает связанность и где это уместно.

    6. Замечание о важности баланса: как не написать случайно DSL, следуя принципам из статьи.

    7. Вместо заключения я скажу почему такого рода идеи вообще приходят людям в голову.

    Почему статическую типизацию можно не любить

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

    Неполнота

    Начнем с того, что какой бы мощной ваша система типов не была, всегда найдутся корректные программы, которые будут ею отвергнуты. С этим хорошо знакомы разработчики на Rust (читайте статьи о non lexical lifetimes: раз, два, три, четыре), но проблема касается любой статической системы типов. Это прямое следствие теоремы Гёделя о неполноте (ещё по теме советую прочитать книгу "ГЭБ: эта бесконечная гирлянда"). Она, грубо говоря, гласит что в любой достаточно сложной формальной системе либо есть теоремы, которые верны, но их верность нельзя доказать в рамках самой системы, либо можно доказать теоремы, которые не верны.

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

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

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

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

    Вот маленький пример на Java:

    Optional<? extends CharSequence> x = getContent();
    /*
    Не компилируется с ошибкой: 
    incompatible types: java.lang.String cannot be converted to
    capture#1 of ? extends java.lang.CharSequenc
    */
    CharSequence y = x.orElse("");
    // А с кастом компилируется и прекрасно работает:
    // CharSequence y = ( (Optional<CharSequence>) x).orElse("");

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

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

    В x лежит Optional — реализация монады maybe в Java, а в Rust и Scala оно ещё называется Option. В C# такого нет, поэтому для простоты скажу, что внутри просто лежит nullable ссылка на объект, а сам Optional предоставляет безопасные методы для работы с ним. В частности метод Optional.orElse возвращает либо этот внутренний объект, если он не равен null, либо переданный в аргумент объект.

    Синтаксис ? extends CharSequence значит, что внутри лежит объект, реализующий интерфейс CharSequence. В Java "" имеет тип String, который реализует CharSequence.

    Очевидно, что какой бы CharSequence не был в x его можно присвоить в y, но конечно же и "" тоже можно присвоить. Поэтому программа корректна и кастовать здесь можно. Однако система типов Java не может этого доказать.

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

    Церемонии

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

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

    Справедливости ради вывод типов плохо дружит с перегрузками и неявным приведением типов.

    Логические ошибки

    Типичная логическая ошибка
    Типичная логическая ошибка

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

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

    Мои субъективные ценности

    Хочется всё и сразу
    Хочется всё и сразу

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

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

    Полиморфизм

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

    Нас будет прежде всего интересовать универсальный полиморфизм
    Нас будет прежде всего интересовать универсальный полиморфизм

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

    void qsort (
        void* base, 
        size_t num, 
        size_t size, 
        int (*comparator)(const void*, const void*)
    );

    Но никакой проверки соответствия типов сортируемого массива и компоратора здесь нет. Код же самого компоратора будет кастовать указатели void* к нужному типу.

    Насколько я понял, подобные функции не называют полиморфными примерно по той же причине, почему утиную типизацию не считают видом полиморфизма. Однако, неявное приведение типов это тоже ad-hoc полиморфизм, поэтому нельзя говорить, что в C совсем нет полиморфизма (более того, в новых стандартах есть полиморфные макросы). Люди по-всякому выкручиваются и пишут об этом статьи.

    В Java же есть ещё 3 вида полиморфизма. Два универсальных: параметрический (с помощью дженериков) и включений (через наследование), а так же один ad-hoc: через перегрузку функций.

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

    Перегрузка функций

    Перегрузка функций — неоднозначная фича. Её минус в том, что не очевидно какой на самом деле код будет вызван. Более того, в перегруженных функциях нередко возникает дублирование кода, что в свою очередь приводит к багам: когда одну из реализаций забывают поправить. Скажем, в Rust перегрузок функций нет, а другие виды полиморфизма есть.

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

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

    Но в Java такого механизма нет, поэтому, уверен, каждый из вас писал такой код:

    class Builder {
        void addNames(String... names) {addNames(List.of(names))}
        void addNames(Iterable<String> names) {/*...*/}
    }

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

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

    Не забудьте так же проверить поломку механизма вывода типов: даже если в языке нет вывода типов, как отдельной фичи, он всё равно есть ограниченный, если в языке есть дженерики или лямбды.

    Вот скажем пример, как не надо делать:

    // Метод run принимает функцию из String в T и возвращает T для пустой строки.
    <T> T run(Function<String, T> x) {
        return x.apply("");
    }
    
    // Метод run принимает функцию из String в ничего,
    // вызывает её с пустой строкой и тоже ничего не возвращает.
    void run(Consumer<String> x) {
        run(y-> {
            x.accept(y);
            return null;
            });
    }
        
    void doWork() {
        run(x-> System.out.println(x));             // Не компилируется.
        run((String x)-> System.out.println(x));    // А это компилируется кстати.
    }

    Если вы не понимаете этот пример — это нормально: я сам до конца его не понимаю. При компиляции Java сообщает, что оба метода run(Function) и run(Consumer) подходят, и она не может выбрать какой вызов сгенерировать, хотя на самом деле это не так: если стереть метод run(Consumer), тогда программа продолжит некомпилироваться, т.к. в переданной лямбде нет возвращаемого значения, и конечно, она не подходит в run(Function). Но самое удивительное, что программа начинает компилироваться, если подсказать ей тип аргумента, хотя уж в нём-то, казалось бы, нет никакого сомнения.

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

    Полиморфизм включений

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

    Обычно полиморфизм включений реализуется с помощью динамической диспетчеризации — виртуальных вызовов.

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

    Допустим, есть класс ClassA, у него есть потомок ClassB, а у него потомок ClassC. И есть три метода foo, bar, baz у каждого класса. Причем метод foo вызывает метод bar, а тот вызывает baz. Тогда если ClassB переопределяет foo и baz, а ClassC только baz, то будет очень сложно понять какая цепочка вызовов образуется, если позвать ClassC.foo(). При чтении с вами случится вот что: вы нажмете перейти к декларации у ClassC.foo() попадете в ClassB.foo() там перейдете в ClassA.bar а оттуда в ClassA.baz, а надо было прийти в ClassC.baz. Реальная история, кстати, одного известного опенсорс проекта, все имена заменены.

    Даже с картинкой не сразу понятно, правда?
    Даже с картинкой не сразу понятно, правда?

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

    А вот наследование интерфейсов — ключевая вещь, без которой остальные трюки не будут работать.

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

    Хорошим примером является List.of — метод в стандартной библиотеке Java, который создает неизменяемый List. Если ему передать пустой массив, то новых объектов не будет создано и вернется единственный на всех пустой лист. Для одного и двух элементных массивов возвращается класс List12, который может хранить до 2х элементов, что экономит на аллокации массива и его заголовка, и только для бОльших массивов используется реализация, которая хранит склонированный массив. При этом ничто не помешает добавить ещё реализаций, если потребуется.

    Параметрический полиморфизм

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

    Кажется, нет двух одинаковых реализаций параметрического полиморфизма, поэтому что бы сохранить language agnostic стиль статьи, я опущу множество важных java-специфичных деталей, но даже и без них остается много трудностей. Давайте я расскажу в чем, собственно, проблема.

    Вариантность

    Допустим, у нас есть дженерик класс List<T>, и два обычных класса: X и его наследник Y. Мы написали метод, который принимает List<X>, хотим ли мы разрешать передавать в него ещё и List<Y>? С одной стороны, это было бы гибко, но с другой — это не всегда безопасно. Например, если у X есть ещё потомок Z, тогда, отправив List<Y>, мы начнем работать с ним как с List<X>: положим туда Z, и тогда пользоваться исходным листом как листом List<Y> будет уже нельзя. Случится то, что в java называется heap pollution. Добиться такого поведения для коллекций без кастов нельзя (а для массивов можно, но мы это здесь опустим)

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

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

    Есть всего три вида вариантности:

    1. Инвариантность — можно передавать только в точности тот же тип: не гибко, зато никаких сюрпризов. По умолчанию в Java все дженерики инвариантны.

    2. Ковариантность — в нашем примере это ситуация, когда передать List<Y> можно. Обычно используется для чтения. В Java записывается как List<? extends X>.

    3. Контрвариантность — ситуация обратная, когда принимая List<Y> разрешено за одно принять и List<X>. Обычно используется для записи. В Java записывается как List<? super Y>.

    В Rust, например, тип вариантности выбирается автоматически из контекста. В Java и C# их нужно задать руками и принципы там немного разные. Свои минусы и плюсы есть у всех подходов.

    Стоит отметить, что сегодня считается, что подход, который выбрала Java не самый лучший. Например, в Kotlin все немного переделали. Проблема в том, что использование вайлдкартов (знаки вопроса) в Java не редко порождает нежизнеспособные объявления.

    Здесь я буду вынужден коснуться деталей реализации дженериков в Java. Когда принимаете List<? extends X> языку необходимо как-то запретить вам добавлять элементы в этот лист, чтобы избежать heap pollution. Java поступает очень просто: она запрещает передавать в аргументы методов, где фигурирует дженерик тип, что либо кроме null. Или строже: если ковариантный дженерик тип находится в аргументе метода, то единственное допустимое его значение это null, а если он указан как возвращаемое значение, тогда он равен указанным границам (т.е. для List<? extends X> это X).

    Ровно по этой причине ломаются методы в духе orElse(T default) из примера в начале статьи: если T объявлен как ? extends CharSequence передавать в такой метод можно только null, хотя метод T get() вернет объект типа CharSequence. Java не знает что делает метод — читает или пишет, но если ни одного объекта нельзя передать, то и сохранить его нельзя. А сохранение null не вызовет heap pollution.

    Аналогично и с контрвариантностью: для List<? super Y> вызывать метод add(T) можно только с объектами типа Y, но вызвав T get(int) получится объект типа Object. Контрвариантность используется для записи и сделана, чтобы можно было сохранять объекты типа Y не только в List<Y>, но и в List<X> и List<Object>, поэтому нет никаких ограничений на то, что может вернуть метод get.

    Сегодня, когда индустрия много лет черпает вдохновение из ФП, большинство наших классов стали иммутабельными. И хочется прежде всего хорошей поддержки иммутабельных объектов, а они всегда могут быть ковариантными без всяких ограничений, т.к. в них ничего нельзя записать и heap pollution невозможен в принципе.

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

    Вложенные дженерики

    Во время использования вложенных дженериков всплывает разница между наследником и подтипом. Скажем, List<Y> подтип List<? extends X>, хотя наследования там нет. Поэтому если вы хотите метод, который может принять как Map<K, List<X>>, так и Map<K, List<Y>>, то тип аргумента будет: Map<K, ? extends List<? extends X>>. Тип это вообще больше, чем просто конкретный класс. Если же написать Map<K, List<? extends X>>, то вы не сможете передать туда ни Map<K, List<Y>>, ни даже Map<K, List<X>>, т.к. ожидается конкретно тип List<? extends X> и ничего другого, потому что эта декларация сама является дженерик параметром, а они по умолчанию инвариантны.

    Вывод типов

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

    Обычно мы не замечаем этого механизма и он просто работает.

    Оптимизация виртуальных вызовов через параметрический полиморфизм

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

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

    Cадимся на шею системе типов

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

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

    Допустим, мы хотим написать дженерик функцию, которая считает сколько есть объектов типа T в коллекции source, не считая объектов из blacklist. Напишем её так:

    <T> int filterCount(Collection<T> source, Set<T> blacklist) {
        if (blacklist.isEmpty()) {
            return source.size();
        }
       return (int) source.stream().filter(x->!blacklist.contains(x)).count();
    }

    Обратите внимание на то, что у blacklist тип Set.

    В Java, если упрощать, такая иерархия наследования коллекций: сперва идёт Collection, потом от него наследуется Set, List и некоторые другие. И у интерфейса Collection тоже есть метод contains, поэтому ничто нам не мешает использовать его вместо Set.

    Однако подразумевается, что операция contains будет быстро работать у Set: за O(1) для HashSet или в крайнем случае за O(log n) для TreeSet. Здесь можно чуть-чуть порассуждать о кастомных Set-ах, но в целом, сознательное использование интерфейса Set ценой очень незначительной потери в гибкости позволяет увернуться от перформенсного бага в будущем. И всё благодаря системе типов.

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

    Парси, а не валидируй

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

    Допустим, у нас есть таблица в БД и есть два варианта схемы для неё: старая и новая. Пусть схема это просто мапа String->String из имени колонки в её тип, а хотим мы вычислить изменение в схеме, чтобы дальше что-то с ним сделать: распечатать его скажем.

    Наивный разработчик сделает так: заведёт класс SchemaDiff и у него будет поле String name и два Nullable поля с типом для первой и второй таблицы соответственно.

    final class SchemaDiff {
        final String name;
        final @Nullable String oldType;
        final @Nullable String newType;
    
        SchemaDiff(
            String name, 
            @Nullable String oldType, 
            @Nullable String newType
        ) {
            this.name = name;
            this.oldType = oldType;
            this.newType = newType;
        }
    
        @Override
        public String toString() {
            if (oldType != null && newType != null) {
                return String.format(
                    "Column %s changed the type: %s->%s",
                    name,
                    oldType, 
                    newType
                );
            }
            if (oldType == null) {
                return String.format(
                    "Column %s with type %s has been added", 
                    name, 
                    newType
                );
            }
            return String.format(
                "Column %s with type %s has been removed", 
                name, 
                oldType
            );
        }
    }

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

    Метод toString показывает сложность работы с таким объектом. Скажем, придется затратить некоторые усилия, чтобы понять почему в последней строчке oldType не может быть равен null.

    Правильный же способ, минимизирующий логические ошибки, это создать три класса: RemovedColumn, AddedColumn и TypeChanged. Стоит унаследовать их от общего класса SchemaDiff, чтобы было удобнее обрабатывать их вместе.

    abstract class SchemaDiff {
        final String name;
    
        protected SchemaDiff(String name) {
            this.name = name;
        }
    }
    
    final class RemovedColumn extends SchemaDiff {
        final String type;
    
        RemovedColumn(String name, String type) {
            super(name);
            this.type = type;
        }
    
        @Override
        public String toString() {
            return String.format(
                "Column %s with type %s has been removed", 
                name, 
                type
            );
        }
    }
    
    final class AddedColumn extends SchemaDiff {
        final String type;
    
        AddedColumn(String name, String type) {
            super(name);
            this.type = type;
        }
    
        @Override
        public String toString() {
            return String.format(
                "Column %s with type %s has been added", 
                name, 
                type
            );
        }
    }
    
    final class TypeChanged extends SchemaDiff {
        final String oldType;
        final String newType;
    
        TypeChanged(String name, String oldType, String newType) {
            super(name);
            this.oldType = oldType;
            this.newType = newType;
        }
    
        @Override
        public String toString() {
            return String.format(
                "Column's %s type has been changed: %s->%s", 
                name, 
                oldType, 
                newType
            );
        }
    }

    Таким образом мы переносим все смыслы на уровень типов, где им самое место, а данные остаются абстрактными без всяких смыслов. Поэтому из метода toString ушли все условные переходы.

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

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

    Дедубликация кода с помощью параметрического полиморфизма

    Любой полиморфизм позволяют схлопывать одинаковые с точностью до типов графы вычислений в один граф
    Любой полиморфизм позволяют схлопывать одинаковые с точностью до типов графы вычислений в один граф

    Как-то раз я подготавливал код к миграции, для этого мне надо было вбить небольшой временный костылек такого вида:

    void process(List<Item> items) {
        if (isLegacy) {
            List<Legacy> docs = items.stream()
                .map(x -> toLegacy(x))
                .collect(toList);
            legacyTable.store(docs);
            logEvent(docs.stream().map(x->x.getId()).collect(toList()));
        } else {
            List<Modern> docs = items.stream()
                .map(x->toModern(x, context))
                .collect(toList);
            modernTable.store(docs);
            logEvent(docs.stream().map(x->x.getId()).collect(toList()));
        }
    }

    И проблема в том, что типы Legacy и Modern разные. Методы toLegacy и toModern тоже разные, и у них разное число аргументов. Так же legacyTable и modernTable не только физически разные таблицы, но и разного типа содержат объекты.

    Но при этом высокоуровнево бизнес-логика одинаковая. Вообще ситуация: типы разные, а бизнес-логика одинаковая — это звоночек что пора обмазываться полиморфизмом.

    Дублирование кода — не мне вам объяснять — это прежде всего источник багов.

    И этот код можно дедублицировать, введя такой метод:

    <T extends WithId> List<T> store(
        List<Item> items,
        Function<Item, T> mapper,
        Table<T> table
    ) {
        result = items.map(mapper).collect(toList());
        table.store(result);
        return result;
    }

    и переписать основной так:

    void process(List<Item> items) {
        List<? extends WithId> docs = isLegacy ?
            store(items, x -> toLegacy(x), legacyTable) : 
            store(items, x -> toModern(x, context), modernTable);
        logEvent(docs);
    }

    Сигнатуру logEvent тоже надо подправить, чтобы она принимала любые списки, у которых есть id.

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

    Вывод типов

    Вообще, то, что делает ключевое слово var в Kotlin, C# или в Java, не совсем правильно называть выводом типов, т.к. никакого "вывода" там не происходит: настоящий вывод типов способен определять тип объекта по его использованию.

    На Java:

    var list = new ArrayList<>(); // К сожалению list будет иметь тип ArrayList<Object>.
    list.add(1);

    На Rust:

    let mut vec = Vec::new();   // vec будет иметь тип Vec<i32>
    vec.push(1);

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

    Сперва немного отвлечемся. Думаю, вы слышали, что некоторые кодстайлы рекомендуют вместо такого кода:

    HashMap<String, String> map = new HashMap<>();

    Писать такой код:

    Map<String, String> map = new HashMap<>();

    Мотивация такова: если вы захотите поменять HashMap на TreeMap или какой-то другой Map, то вы внесёте меньше изменений в файл. Очень удобно, но работает только, если между объектами есть наследование.

    На моем старом проекте на Java 11 пересели не так давно, а на новом проекте уже я не так давно, поэтому истории успеха с var у меня нет и здесь будет умозрительный, но правдоподобный пример.

    Допустим, у вас был метод какой-то такой:

    Long2LongOpenHashMap createMap(long[] keys, long[] values);

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

    Но потом метод стал популярным, обнаружились проблемы производительности, и вы захотели возвращать все же интерфейс с разными реализациями под разный размер данных, например.

    Long2LongMap createMap(long[] keys, long[] values);

    Если при этом использование было каким-то таким:

    var map = createMap(keys, values);
    for(long x: xs) {
        f(map.get(x));
    }

    то такие файлы не будут затронуты рефакторингом! Код программы не изменится, а типы поменяются, т.к. никаких противоречий по API нет. Если же тип писался явно, то потребуется в ручную рефакторить возможно сотни файлов — можно случайно и лишнее что-то задеть (у меня такое было).

    Вычисления над типами и лифтинг

    Итак, реальная задача. У нас есть сервис, который считает ML-ные фичи для потока документов, и мы делаем API доступа к ним. Сервис кладет данные в БД, а пользователи из БД читают. Так что балансировка нагрузки и прочее — уже решенная задача. Важно то, что у фичей есть версии — при обновлении конфигурации появляется новая версия, она тестируется и потом применяется вместо старой. Есть возможность откатить и прочая бизнес-логика.

    Разные виды фичей, допустим, лежат в enum-е:

    enum FeatureType {
        ELMO,
        SLANG,
        LIFETIME_7D,
    }

    и каждая фича имеет свою таблицу со своей схемой. Скажем, для ELMO это EmbeddingEntry — массив float, для LIFETIME_7D — это FloatEntry, один float — вероятность, что через 7 дней новость устареет, а для SLANG вообще BlacklistEntry — список найденных матных слов в тексте. Все они наследуются от FeatureEntry, в котором ещё лежит id документа, к которому эта фича относится.

    И вот мы делаем, допустим, такое простое API:

    <TEntry extends FeatureEntry> Collection<TEntry> find(Collection<Id> ids, FeatureType type);

    По id документа и типу фичи возвращаем значения фичей. Внутри в этом методе есть логика по выбору нужной версии.

    Внимание вопрос: как узнать какой именно TEntry соответствует какому FeatureType? Сейчас никак нельзя, чтобы работала сериализация придется сделать так:

    enum FeatureType() {
        ELMO(EmbeddingEntry.class),
        SLANG(BlacklistEntry.class),
        LIFETIME_7D(FloatEntry.class),
        ;
    
        private final Class<? extends FeatureEntry> entryClass;
    
        public FeatureType(Class<? extends FeatureEntry> entryClass) {
            this.entryClass = entryClass;
        }
    
        public Class<? extends FeatureEntry> getEntryClass() {
            return entryClass;
        }
    }

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

    Теперь сериализатор в рантайме получит класс, в который надо сериализовывать. Однако в методе find тип TEntry не может быть выведен на этапе компиляции, и пользователям придется кастить. Причем в реальном проекте фичей не 3, а 56, так что если вы там думали три метода завести, не стоит.

    Но можно сделать вот что:

    final class FeatureType<TEntry extends FeatureEntry> {
        public static final FeatureType<EmbeddingEntry> ELMO = 
            new FeatureType("elmo", EmbeddingEntry.class);
        public static final FeatureType<BlacklistEntry> SLANG = 
            new FeatureType("slang", BlacklistEntry.class);
        public static final FeatureType<FloatEntry> LIFETIME_7D = 
            new FeatureType("lifetime_7d", FloatEntry.class);
    
        private final Class<TEntry> entryClass;
        private final String name;
        private FeatureType(String name, Class<TEntry> entryClass) {
            this.name = name;
            this.entryClass = entryClass;
        }
    
        public String getName() {
            return name;
        }
    
        public Class<TEntry> getEntryClass() {
            return entryClass;
        }
    }

    Пользовательский код не изменится: будет выглядеть, как будто это старый добрый enum. Однако тип ентри станет виден — мы добавили его в дженерик параметр — и API станет типобезопасным:

    <TEntry extends FeatureEntry> Collection<TEntry> find(
       Collection<Id> ids, 
       FeatureType<TEntry> type
    );

    Причем теперь можно делать типобезопасные API, которые работают только с фичами, которые выплевывают какой-то конкретный тип данных. И новые фичи, которые генерируют выход такого же типа, сразу будут применимы к этому API.

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

    Когда я это придумывал, я вдохновлялся идеей "зависимых типов", однако, как позже мне подсказали знатоки ФП, на самом деле здесь я сделал лифтинг на уровне типов.

    Обычно лифтинг превращает функцию над одними объектами в функцию над другими. Например так:

    static <TLeft, TRight, TResult> 
    BiFunction<Optional<TLeft>, Optional<TRight>, Optional<TResult>> 
    lift(BiFunction<TLeft, TRight, TResult> function) {
        return (left, right) -> left.flatMap(
            leftVal -> right.map(rightVal -> function.apply(leftVal, rightVal))
        );
    }

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

    Я же, снабдив каждый инстанс FeatureType дженерик параметром, фактически создал "функцию" из инстансов FeatureType в типы FeatureEntry. В результате мне стало доступным, записывая рантаймовые вычисления над объектами типа FeatureType, делать компайлтаймовые "вычисления" над типами наследниками FeatureEntry.

    Допустим, теперь, что наши фичи бывают разных видов. Есть те, что считаются отдельно для каждой страны, а есть те, что считаются отдельно для каждого языка. Скажем, отношение к мату в США и Англии разное, хотя язык один и тот же. Таким образом получается, что один и тот же документ может иметь разные значения каких-то фичей в разных странах, а значит её надо указывать в запросе. А MLным фичам важен язык безотносительно страны.

    Здесь мы воспользуемся перегрузкой:

    <TEntry extends FeatureEntry> Collection<TEntry> find(
        Collection<Id> ids, 
        FeatureType<TEntry> type, 
        Language language
    );
    
    <TEntry extends FeatureEntry> Collection<TEntry> find(
        Collection<Id> ids, 
        FeatureType<TEntry> type, 
        Country country
    );

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

    Можно это сделать так:

    class FeatureType<TEntry extends FeatureEntry> {
        public static final ByLanguage<EmbeddingEntry> ELMO =
            new ByLanguage<>("elmo", EmbeddingEntry.class);
        public static final ByCountry<BlacklistEntry> SLANG = 
            new ByCountry<>("slang", BlacklistEntry.class);
        public static final ByCountry<FloatEntry> LIFETIME_7D = 
            new ByCountry<>("lifetime_7d", FloatEntry.class);
    
        private final Class<TEntry> entryClass;
        private final String name;
        private FeatureType(String name, Class<TEntry> entryClass) {
            this.name = name;
            this.entryClass = entryClass;
        }
    
        public String getName() {
            return name;
        }
    
        static final class ByLanguage<TEntry extends FeatureEntry>
            extends FeatureType<TEntry> {...}
        static final class ByCountry<TEntry extends FeatureEntry> 
            extends FeatureType<TEntry> {...}
    }

    И тогда API будет:

    <TEntry extends FeatureEntry> Collection<TEntry> find(
        Collection<Id> ids, 
        FeatureType.ByLanguage<TEntry> type, 
        Language language
    );
    
    <TEntry extends FeatureEntry> Collection<TEntry> find(
        Collection<Id> ids, 
        FeatureType.ByCountry<TEntry> type, 
        Country country
    );

    При этом код использования останется прежним.

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

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

    Опять-таки, мы написали больше кода объявлений, но повысили надежность. Конечно, если бы в Java можно было делать хотя бы generic enum-ы с наследованием, то код существенно бы сократился — его большой размер следствие вербозности языка, а не заумности концепции. Как раз чтобы разделять эти две ситуации, и стоит изучать концепции из академических языков.

    Развязывание

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

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

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

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

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

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

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

    Справа внизу не менеджер, а Шоггот
    Справа внизу не менеджер, а Шоггот

    DSL vs composability

    Как и у любого подхода, здесь тоже можно увлечься и сломать какие-то важные свойства вашей программы. Например, если слишком часто перекладывать данные из одних классов в другие, только чтобы сделать маркировку типа, в языке вроде Java это может привести к проблемам с производительностью. Но я бы обратил внимание на ситуацию, когда нагромождение классов создает что-то вроде DSL и ломает composability (наверное можно перевести как комбинируемость, компануемость или сочетаемость).

    DSL не всегда пишется осознанно: иногда вы пишете код или даже рефакторите старый, выделяете общие сущности, пытаетесь сделать безопасный и минималистичный API, а в какой-то момент у вас получается DSL. Поначалу всё прекрасно, многие вещи очень быстро встраиваются, но в какой-то момент оказывается, что новые бизнес-задачи требуют другого DSL. На практике это приводит к костылям, т.к. времени, что бы переписать DSL и всё мигрировать, никогда не бывает.

    Если вы когда-нибудь ловили себя на мысле, что надо переписать половину сервиса заново, чтобы нормально внедрить эту маленькую фичу — это оно. Проблема возникает потому, что разные средства языка имеют разную степень composability.

    Допустим, вы заметили, что у вас часто возникает такой тип: CompletableFuture<Optional<T>> и вы можете написать много полезных методов для работы с ним. Конкретно в Java вы можете поступить только двумя способами:

    1. Сделать обертку OptionalFuture и добавить методы туда.

    2. Добавить статические методы в какой-нибудь FutureUtils.

    Первый способ считается более ООП-правильным. Но на практике второй способ намного более composable. Дело в том, что ваш OptionalFuture несовместим ни с какими другими библиотеками и методами для работы как с CompletableFuture, так и с Optional — помимо ваших прекрасных методов вам придется проксировать методы CompletableFuture, а их там будь здоров сколько. Причем методы, которые как-то работают с классом CompletableFuture, например, thenCompose (какая ирония), придется дублировать для двух типов и в одну сторону комбинировать будет проще, чем в другую.

    Если говорить о Java, то набор максимально composable конструкций у него совпадает с точностью до нейминга с ядром языка С: вы легко можете комбинировать операторы и вызовы функций, а со всем остальным возникают накладки. Согласитесь, добавить поле в класс куда проще, чем реализовать интерфейс. Да-да, пресловутая композиция vs наследование. Даже лямбды, которые казалось бы созданы для комбинирования, плохо дружат с чекед эксепшенами.

    Это довольно контринтуитивная мысль, но в действительности, по крайней мере в мейнстримных языках, composability находится в некоторой противофазе с теми концепциями, которые я тут описываю. И недавно мне попался потрясающе наглядный пример, который это иллюстрирует в коде Apache Lucene.

    Apache Lucene — это движок для полнотекстового поиска: им пользуется Twitter, и на его основе написан Elasticsearch. У него очень интересный исходный код, в котором чувствуется дух времени: он написан очень умными людьми, но очень давно — сейчас так писать не принято. В частности сайдэффекты там — это часть API.

    Представьте, что вам надо написать сортировку, но так, чтобы один код работал вообще в любых ситуациях, где что-то как-то сортируется: коллекции и массивы (между ними нет наследования в Java), объекты и примитивы (к сожалению, дженерики в джаве не работают с примитивами, и код принимающий T[] отвергнет int[]) и даже в ситуациях, которые не опишешь в двух словах (необъяснимый пример ждет вас впереди).

    Как вы понимаете, условия, которые я поставил выше, ставят разработчика в трудное положение. Если воспринимать сортировку как функцию то, что у неё на входе? Загвоздка в том, что в Java невозможно выразить "это либо массив типа T, либо коллекция типа T, либо массив примитивов", поэтому вход описать невозможно.

    Однако в Apache Lucene есть класс InPlaceMergeSorter, который это умеет, а работают с ним так:

    // Код немного упрощен:
    private BlendedTermQuery(Term[] terms, float[] boosts, TermStates[] contexts) {
        assert terms.length == boosts.length;
        assert terms.length == contexts.length;
        this.terms = terms;
        this.boosts = boosts;
        this.contexts = contexts;
        // Поля terms, boosts и contexts массивы с одинаковой длиной
    
        
        // Обратите внимание на пустой конструктор: все нужные нам аргументы мы захватываем.
        new InPlaceMergeSorter() {
          // Сортируем казалось бы массив terms, по крайней мере сравниваем его.
          @Override
          protected int compare(int i, int j) {
            return terms[i].compareTo(terms[j]);
          }
    
         // Но на самом деле мы сортируем все три массива, но по значениям из массива terms
         @Override
          protected void swap(int i, int j) {
            Term tmpTerm = terms[i];
            terms[i] = terms[j];
            terms[j] = tmpTerm;
    
            TermStates tmpContext = contexts[i];
            contexts[i] = contexts[j];
            contexts[j] = tmpContext;
    
            float tmpBoost = boosts[i];
            boosts[i] = boosts[j];
            boosts[j] = tmpBoost;
          }
        // Нет возвращаемого значения, т.к. результат функции: сайд-эффект.
        }.sort(0, terms.length);
      }

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

    API этого класса максимально сочетаемо, наверное, нет ни одной в целом свете ситуации, где что-то как-то сортируется, а его нельзя было бы применить. Здесь по сути нет никакого API: конструктор пустой, переменные захватываются, а на выходе — сайдэффекты.

    Одновременно с этим это API максимально опасное, ведь легко написать такое:

    Foo(float[] boosts) {
        this.boosts = boosts.clone();
    
        new InPlaceMergeSorter() {
          @Override
          protected int compare(int i, int j) {
            return Float.compare(boosts[i], boosts[j]);
          }
    
          @Override
          protected void swap(int i, int j) {
            float tmpBoost = this.boosts[i];
            this.boosts[i] = this.boosts[j];
            this.boosts[j] = tmpBoost;
          }
        }.sort(0, terms.length);
      }

    Это еле заметно, но сравниваем мы тут один массив, а свопаем другой т.к. boosts указывает на аргумент а this.boosts на поле класса, и тут это разные объекты.

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

    Нет какой-то универсальной метрики кода, максимизировав которую, вы достигните нирваны. Увеличивая надежность, вы в какой-то момент начнете непростительно терять в composability, а безрассудно увеличивая composability, вы растеряете всякую осмысленность.

    При проектировании API надо взвешивать каждый шаг и иметь широкий кругозор. На расширение кругозора и направлена статья.

    Рассуждение о локальности

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

    Если представить, что внимания и памяти разработчика хватает на скоуп только одной функции, тогда без того, чтобы класть побольше информации в тип, не обойтись. В следующей функции вы просто не вспомните проверял ли вы массив на пустоту или нет, null твои аргументы или не null. Передать эту информацию можно только с типом. А если представить, что внимания и памяти разработчика хватает на всю программу целиком, тогда все это наоборот оказывается не нужным, и даже вредным. Зачем мне проверять, пусть даже статически, что я кладу совместимый объект? Я и так держу в голове всю программу и помню что можно класть в какие методы, а что нельзя. Аналогично рассуждают некоторые плюсовики: мне UB не мешает, я точно помню какие мои переменные могут быть null, а какие нет, в уме просчитываю лайфтаймы всех аллоцированных объектов, а код с гонками не пишу в принципе.

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

    P.S.

    Я не ругаю C. Мне просто нужен был пример очень простой статической системы типов для противовеса.

    Благодарности

    Большое спасибо этим людям за ревью статьи до публикации:

    1. Дмитрий Юдаков

    2. Дмитрий Петров

    3. Николай Мишук

    4. Анастасия Павловская

    5. Полина Романченко

    6. Светлана Есенькова

    7. Ян Корнев

    Комментарии 68

      +5

      Отличный слог. Автор, пиши еще!


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


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

        +1

        Спасибо! А то как закопаешься в свой Энтерпрайз и не думаешь ни о чем.
        Буду рад видеть ещё ваши статьи.

          +2
          Неполнота

          Я совсем не настоящий CS, но мне кажется, что тут причина ближе не к Гёделю, а к теореме Райса

            +3

            Именно так (ну, с небольшой коррекцией: не существует разрешимых систем типов, которые типизируют любую корректную программу и не типизируют некорректную).


            На Гёделя ссылаться вообще довольно странно, потому что для начала придётся определить, что значит «корректно решить задачу».

              +1

              Я в этой теме плавую, но в моем понимании это примерно одно и тоже, просто с разных точек зрения. Теорема Райса говорит, что наличие свойств, скажем, отсутствие ошибок некоторого типа невычислимы. А я предлагаю взглянуть на программу как на доказательство теоремы о своей корректности в узком смысле, и тогда можно рассуждать в рамках теоремы Гёделя.
              Наверное ссылка на Райса вызвала бы меньше вопросов, но мне кажется важным акцентировать внимание на том, что не просто компилятор проверяет какие-то свойства как бы сам по себе, а что код программы является доказательством наличия этих свойств, которое верифицирует компилятор.
              Грубо говоря, когда ты пишешь программу на статически типизированном языке ты как бы пишешь две программы одним и тем же кодом: вычисление над типами и вычисление над данными. Про первое мне кажется удобнее рассуждать в терминах теорем, а про второе в терминах вычислимости и свойств.

                +2

                Я тут тоже ещё подумал, и как-то внезапно понял, что Гёдель-то ведь на самом деле про обратное: если ваша логика (то есть, система типов) позволяет выразить арифметику, то существует утверждение (то есть, некий тип Ty), который нельзя доказать (то есть, не существует терма с этим типом), и который нельзя опровергнуть (то есть, не существует терма с типом Ty → ⊥).


                В каком-то смысле он про неполноту языка термов, а не языка типов.


                А то, что вы рядом пишете — это стандартный Карри-Говард.

                  +1
                  Говорю же тут немного плавую. Знаю что изоморфизм есть, но не знал даже как он называется:) Про термы не понял, что такое терм в данном случае? Какое-то объявление типа или выражение?
                  И кстати как тебе статья в целом? Твое мнение было бы мне важно т.к. я на несколько твоих статей опираюсь здесь и даю на них ссылки:)
                    +1
                    Про термы не понял, что такое терм в данном случае? Какое-то объявление типа или выражение?

                    По большому счёту тело функции.


                    И кстати как тебе статья в целом? Твое мнение было бы мне важно т.к. я на несколько твоих статей опираюсь здесь и даю на них ссылки:)

                    Статья хорошая (хоть я и не могу оценить те части, что про джаву и про ООП). Чем больше людей будет знать, что типы — это не когда долбанный компилятор не даёт скомпилировать код, а когда друг и помощник помогает не совершать ерунды и делать код более выразительным, тем в лучшем мире мы будем жить.

                +1

                С утра ещё подумал и надо наверное пояснить почему программа это теорема. Теорема по сути это набор аксиом и последовательность применённых к ним правил вывода. А программа это набор входных типов и последовательность функций которые переводят один тип в другой. Компилятор верифицирует, что действительно есть такие функции, которые могут так трансформировать типы. Верификация теоремы проверяет, что действительно все правила вывода применены корректно. Один и тот же процесс по сути, только вместо типов утверждения. Легко показать, что для любой теоремы существует эквивалентная ей программа, так как надо всего лишь задать по типу для каждого утверждения. В обратную сторону это тоже работает. Если выписать все функции как правила вывода, а все типы как утверждения.
                Без Гёделя возникает мысль что раз доказательство наличия свойства я пишу сам, то может быть удастся его построить даже если в общем виде такая задача невычислима. Гëдель же говорит, что доказательства нет вовсе.
                Там кстати метод обхода похожий: делаешь функцию с кастом, но безопасным — как бы добавляешь геделевскую теорему как аксиому.

              +1
              Мне очень понравилось, честно. Сразу видно, человека, который разбирается в материале. Побольше бы таких, респект.
                +1

                Увидел эту статью ещё в четверг, решил отложить чтение до более спокойного момента – и не пожалел. Её действительно надо было читать на свежую голову.


                Отдельное спасибо за упомянутый мимоходом момент с Map<K, ? extends List<? extends X>>. Я однажды пытался сделать что-то такое, но оно у меня даже с List<? extends X> не завелось, и мы решили сделать три функции с разными именами, не совладав с мощью джененриков. Надо будет попробовать сделать через более всеобъемлющщую ковариантность.


                Спасибо за статью и за ссылки, это было прекрасно. Осталось только усвоить прочитанное. :D

                  +1
                  Ради таких отзывов и хочется писать :)
                  +1
                  Зачем мне проверять, пусть даже статически, что я кладу совместимый объект? Я и так держу в голове всю программу и помню что можно класть в какие методы, а что нельзя.
                  Я не помню, а понимаю. Точно так же, как я не помню книжку на 500 страниц, но понимаю концепции, изложенные в ней, и могу пользоваться ими, если я их понял.
                    0

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

                      0
                      Вот кстати интересный пример про книгу. Скажем Преступление и наказание около 600 страниц. И в начале, если помните, там главный герой убивает старушку, а ещё свидетельницу: молодую девушку. Однако, в конце книге про свидетельницу нет уже ни слова. А была бы у Достоевского статическая типизация, книга бы до печати не дошла пока бы он не поправил несоответствия. Ну и подобных историй в литературе предостаточно.

                      Что касается концепций, признаться не могу понять в чем аргумент. У меня например постоянно такое, что концепции одинаковые (по крайней мере в моей голове), а код всё таки приходится подправлять.
                        +1
                        Что касается концепций, признаться не могу понять в чем аргумент.
                        Я имею в виду, что я не программу держу в голове, а концептуальную репрезентацию (ближайшая аналогия — направленный граф, от main() к функциям самого низкого уровня). Достаточно взять конкретные ветки этого графа, чтобы знать, откуда пришли данные, что с ними происходило, и в какой форме они могут быть в конкретном месте в программе.

                        Звучит так, как будто программа пишется так же, как исполняется её исходный код, в виде некоторого пайплайна.
                        Да, примерно так и пишется изначально.
                          –1
                          Да, примерно так и пишется изначально.

                          Самое интересное, что я не читал ваш комментарий когда ответил точно так же ниже. Это важный ключевой момент.


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

                          Да, это и похоже на перемещение контекста вместе с процессом исполнения.

                            0
                            Самое интересное, что я не читал ваш комментарий когда ответил точно так же ниже. Это важный ключевой момент.
                            Это, я подозреваю, от того, что писать программы вдоль control flow — естественно, если тебя специально не учат по-другому.
                            +1
                            Достаточно взять конкретные ветки этого графа, чтобы знать, откуда пришли данные, что с ними происходило, и в какой форме они могут быть в конкретном месте в программе


                            А как ты понимаешь глядя в локальную точку графа откуда пришли данные и какова их природа без типов? Вот если ты пришел в проект новый и тебе дали задачу на шаге X сделать ещё одно действие Y. Или все таки держать в голове весь путь от инпута до текущего шага исполнения обязательно? Тогда кажется я все корректно описал про «держать всю программу в голове»:-)
                              0
                              Или все таки держать в голове весь путь от инпута до текущего шага исполнения обязательно?
                              Иметь представление обязательно. О том, с чем связан кусок кода, с которым я работаю прямо сейчас. И в любой системе, работая с её частью нужно иметь представление, о том, как изменения коснутся системы в целом. По крайней мере в моей картине мира.
                                –1

                                Тут на самом деле всё просто — названия и самостоятельный вывод типов.
                                Если к вам в функцию check_name приходит аргумент my_name а потом ещё и в следующих строчках идёт работа с текстом то ты не особо задумываешься.
                                Это классическая проблемма людей которые приходят к ДТ из СТ, вы начинаете проектировать программу, строить абстракции а так как типов в интерфейсах нет вы быстро в этом тонете т.к. держать в голове всё невозможно.

                                  0

                                  Для имён это и правда просто. А когда у вас есть gulp, который использует vinyl-fs, который использует vinyl, а вам надо всего-то понять как прочитать имя и содержимое файла, пришедшего к вам через пайп…


                                  Или когда у вас есть API Яндекс-карт, и вы пытаетесь передать опции компоненту Clusterer, но создаёте вы его не напрямую, а через ObjectManager…

                                    0

                                    Хорошую документацию типы не заменяют. Я согласен что в простых случаях вам это может быть достаточно но в случае с любым Builder вам всёравно лучше глянуть документацию и изучить методы/поля.


                                    К слову в Rollup с этим по проще. ) Это я к тому что ДТ куда более чувствителен к хорошему дизайну, увы в JS люди не так сильно знакомы с Zen Python. Собственно я уже говорил тут что "простота" и "явность" особенно важны для ДТ.

                                      +2

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


                                      Скажем, в первом моём примере поддерживающая тайпинги (при их наличии) IDE сразу же подскажет, что пайплайн gulp — это стандартный нодовский стрим, по которому передаются объекты Vinyl из пакета vinyl. И дальше можно сразу же смотреть документацию на vinyl, пропустив этап ознакомления с gulp и vinyl-fs.


                                      К слову в Rollup с этим по проще. ) Это я к тому что ДТ куда более чувствителен к хорошему дизайну

                                      А откуда он возьмётся, этот хороший дизайн, если писать код в соответствии с вашими советами — без проектирования и строго в порядке его исполнения?


                                      Кроме того, иногда сложность растёт из поставленной задачи. Тот же rollup — это всего лишь бандлер, в то время как gulp — система сборки. А в системе сборки по определению должно быть много плагинов — значит, нужны какие-то абстракции, чтобы эти плагины могли друг друга понять. Ну а если у нас есть абстракции — логично описать их в виде типов.

                                        0
                                        Да, типы документацию не заменяют. Но сильно упрощают навигацию по ней.

                                        И тут я с вами согласен. Хотя это вопрос сильно завязан на процесс подготовки к разработке.


                                        А откуда он возьмётся, этот хороший дизайн, если писать код в соответствии с вашими советами — без проектирования и строго в порядке его исполнения?

                                        Я не призывал отказываться от проектирования, просто это не должно быть самоцелью. Если уж говорить про советы то это KISS, DRY, YAGNI and premature generalization is the root of all evil.


                                        от же rollup — это всего лишь бандлер, в то время как gulp — система сборки.

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

                                  –2

                                  Видимо стоить раскрыть ещё одну тему из моей гипотетической статьи — уверенность.
                                  На тему СТ и ДТ я очень много спорил с моим коллегой и он был ярым поклонником СТ. После очень долгих и детальных обсуждений мы выяснили что мы по разному относимся к риску и понятию "уверенности". Для него было не комфортно быть не уверенным в типе переменной или в поведении, тогда как я не чувствовал никакого дискомфорта. Скорее всего вы пытаетесь «держать всю программу в голове» именно из-за того что не полная уверенность вам некомфортна и вызывает негативные эмоции. Разное отношение к риску основано во многом на вашей генетике и воспитании, сюда же входит насколько импульсивно вы принимаете решения.
                                  Если вам нравится всё контролировать, строить в голове абстракции, любите строгость (в том числе в искусстве) то скорее всего ДТ вам будет крайне не удобно.
                                  Все люди мыслят по разному, но судя по всему есть разные патерны исходящие из сильных и слабых сторон вашего мозга. У кого то хорошо с памятью (причём по отдельности быстрой и долгой), у кого то с аналитическими способностями а кто то силён в творчестве (программирование это в том числе творчество). И это всё влияет на выбор вами ЯП, технологий и прочего. Я надеюсь эти идеи не покажуться вам слишком радикальными.

                                0
                                Что касается концепций, признаться не могу понять в чем аргумент. У меня например постоянно такое, что концепции одинаковые (по крайней мере в моей голове), а код всё таки приходится подправлять.

                                потому что вы программируете через проектирование, а не через исполнение

                              0

                              Отличная статья! Я тут как раз начал писать статью почему динамическая типизация хороша (в зависимости от...). На самом деле вся вторая часть статьи про плюсы статической типизации это отличная иллюстрация где выигрывает динамическая. Это же какая когнитивная нагрузка думать о этих всех дженериках, типах, выводах типов и прочем. :)
                              На самом деле хочется опровергнуть последний пассаж:


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

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


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


                              В итоге программы получаются разные и когнитивные навыки задействуются по разному при написании и мы приходим к тому что СТ vs ДТ зависит от психологии, от образа мыслей конкретного человека (если конечно убрать проблему производительности за скобки).

                                +2
                                но именно поэтому в этих языках важно писать как можно проще (явно как завещал Гвидо), компактнее и выбирать максимально читабельные и полные названия для всего.

                                То же самое можно использовать в языках со статической типизацией и получить также плюсом и проверки компилятора.

                                  0

                                  Я не очень понял на что вы отвечаете… я не говорил что так нельзя писать на ЯП с СТ, я говорил что это очень важно для ЯП с ДТ, а для СТ это не настолько критично (типы дают ту избыточность которая позволяет вам проводить навигацию даже в тонне оверинженерингого кода)

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

                                Звучит так, как будто программа пишется так же, как исполняется её исходный код, в виде некоторого пайплайна. Однако, на практике написание программы скорее напоминает переусложнённую версию игры в Дженга, где вам приходится расширять программу изнутри периодически выделяя общие паттерны в мини-фреймворки, попутно удаляя неиспользуемое легаси. Более того сам характер взаимосвязей между компонентами не похож на поток: низкоуровневые компоненты переиспользуются многократно причем на разных абстрактных уровнях.
                                Сам я сталкивался с таким, что разработчик, не осилив написать типобезопасную версию апи, оставлял торчать Object-ы в декларациях или явные касты в коде и в результате тривиальный рефакторинг ломал всю программу, но понятно это было только при запуске тестов, а хочется же во время компиляции.
                                в этих языках важно писать как можно проще (явно как завещал Гвидо), компактнее и выбирать максимально читабельные и полные названия для всего

                                Писать так программы хорошо на любом языке, но тут мне кажется есть некоторая подмена понятий: хорошо писать код, который выглядит просто и понятно, но писать такой код совсем не просто: от того и идет «когнитивная нагрузка». Более того, если вы пишете библиотечный код, то самое главное, что бы им было просто и удобно пользоваться, а это зачастую значит писать очень сложный код внутри самой библиотеки.
                                  –1
                                  Звучит так, как будто программа пишется так же, как исполняется её исходный код, в виде некоторого пайплайна.

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


                                  Писать так программы хорошо на любом языке, но тут мне кажется есть некоторая подмена понятий: хорошо писать код, который выглядит просто и понятно, но писать такой код совсем не просто: от того и идет «когнитивная нагрузка».

                                  Для ДТ языков это естественно и кроме того вы просто обречёте себя на боль если не будете делать так в ДТ языках (которые это позволяют делать лучше). Ну и у вас меняется мышление по этому особо нагрузки нету.


                                  версию игры в Дженга

                                  именно так выглядит написание программы на языках с СТ. Это особенно чувствуется в Java/C# и возможно в Rust. В C#/Java это усугубляется обязательностью классов и в целом классовым ООП.

                                    0
                                    Ну вот вы написали пайплайн, а потом уволились и мне надо добавить изменений в середину? Или скажем изменить какую-то функцию, которой пользуетесь вы и ещё в нескольких разных местах разные люди? И начинается Дженга, где одно неловкое движение и башня упадет. Или вы просто выбрасываете весь ранее написанный код и переписываете его заново?:-) Я честно не понимаю.
                                      0
                                      Я честно не понимаю.

                                      Надеюсь смогу пояснить.


                                      Ещё один хороший аргумент который постоянно всплывает. С моим коллегой мы выяснили что он в СТ 80% времени тратит на рефакторинг и только 20% на написание нового кода, у меня же обратная пропорция. Очень странно правда? Мы стали выяснять и поняли что:


                                      1. В СТ вы пишите сильно связанный код, где изменения в одном месте обязывает вас менять много других кусков.
                                      2. В ДТ как правило пишут слабо связанный код, где мало связей между модулями (к примеру хендлер у API endpoint).

                                      Ну вот вы написали пайплайн, а потом уволились и мне надо добавить изменений в середину?

                                      Берёте и добавляете, Python или хорошый JS читается как книга, вам в любом случае надо будет разбираться в бизнес логике и что именно программа делает. Невижу тут проблем.


                                      Или скажем изменить какую-то функцию, которой пользуетесь вы и ещё в нескольких разных местах разные люди?

                                      Берёте и меняете… если вы меняете поведение функции то вы либо создаёте новую функцию с новым именем либо (что геморойно и зачем такое вообще?) бегаете поиском по проекту и меняете поведени, но вообще это моветон такое делать не соглосовав с коллегами. Если вы только добавляете аргумент то ничего менять не надо.


                                      И начинается Дженга, где одно неловкое движение и башня упадет.

                                      Не вижу где она тут начинается. Ну и если где то подправить забыли то на тесте упадёт и вы это подчистите.


                                      Или вы просто выбрасываете весь ранее написанный код и переписываете его заново?

                                      Нет, зачем? Если я меняю что то в одном месте это только в крайнем случае влияет на остальную программу.


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

                                        +2
                                        Ещё один хороший аргумент который постоянно всплывает. С моим коллегой мы выяснили что он в СТ 80% времени тратит на рефакторинг и только 20% на написание нового кода, у меня же обратная пропорция. Очень странно правда? Мы стали выяснять и поняли что:

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

                                        В ДТ как правило пишут слабо связанный код, где мало связей между модулями (к примеру хендлер у API endpoint).

                                        Это возможно только в двух случаях. Либо у вас такая задача, которая позволяет писать настолько слабо связный код (и тогда вам никакая СТ не помешает делать то же самое) — либо вы просто копи-пастите один и тот же код каждый раз, когда он вам нужен вместо того чтобы использовать общие модули. Ну или пишете заново, что ещё хуже.

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

                                          Принципиально писать слабо связанный код на СТ мне никто не мешает но на практике получается что:


                                          1. У ДТ нету болилерплейта связанного с типами, и код получается чище (и компилировать не надо).
                                          2. Почему то решения для СТ (библиотеки и прочее) толкают тебя к писанию и сильно связаного кода. Вместо callback функции к примеру, обьявлять класс… или для того что бы записать что то в файл из сети надо реализовать целый конвеер по сохранению.

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

                                            +2
                                            Что-то вы всё в кучу намешали.

                                            1) Благодаря выводу типов, во многих современных языках программирования не так часто требуется явно указывать типы переменных.
                                            2) Динамическая/статическая типизация и компилируемость/интерпретируемость вещи ортогональные — ЯП может быть компилируемым и с ДТ, а может быть интерпретируемым, но с СТ.
                                            3) Какая связь между объявлением класса и связанностью кода?
                                            4) Не уверен, что правильно понял вашу мысль про конвейер, но если вы о чём-то вроде этого, то я совершенно не понимаю, что вы понимаете под связанностью кода. Потому что подобные подходы как раз и предназначены для уменьшения этой самой связанности.

                                            Заодно было бы интересно узнать ваше мнение по вот какому вопросу: если динамическая типизация настолько хороша, почему во многих ДТ-языках (python, php, ruby) тренд на добавление возможностей СТ?
                                              0
                                              ЯП может быть компилируемым и с ДТ
                                              Можно пример?
                                                0

                                                В зависимости от того, как развернуть понятие "компилируемый", можно разные примеры найти:
                                                Objective-C, Common Lisp, Chez Scheme, Julia, C#, Erlang/HiPE, Lua/LuaJIT...

                                                  +2
                                                  В принципе, за компилируемый язык с ДТ вполне себе сойдёт ассемблер.
                                                  В целом же хочу обратить внимание, что вид типизации — это свойство языка, а компилируемость — свойство реализации.
                                                  Например, для Basic существовали и компилятор, и интерпретатор.
                                                    0

                                                    Мало смысла от интерпретатора языка с СТ так как тогда вы не сможете проверить полноценно типы до запуска. Ну и СТ позволяет как правило производить относитльно легко эффективный машинный код чем грех не воспользоваться.
                                                    А компилируемый ДТ не будет во время такой операци опущен до машинного кода по настоящему. (т.е a+b не будет выглядить как комманда процессору a+b ) По сути вы просто смержите интерпретатор или его части с вашей программой. Тут только JIT может помочь хотя это уже runtime.

                                                      +1
                                                      Сколько в этом смысла — вопрос другой.

                                                      Главное, что мы определились, что компилируемость/интерпретируемость и динамическая/статическая типизация — вещи ортогональные.
                                                  –1
                                                  1) Благодаря выводу типов, во многих современных языках программирования не так часто требуется явно указывать типы переменных.

                                                  Вывод помогает при работе с перменными внутри функций, но боилерплейт остаётся при объявлении интерфейсов (это и функции если что).


                                                  2) Динамическая/статическая типизация и компилируемость/интерпретируемость вещи ортогональные — ЯП может быть компилируемым и с ДТ, а может быть интерпретируемым, но с СТ.

                                                  Вопросс во времени, запуск Python, JS, PHP почти моментальный, сборка большого проекта с шаблонами на C++/Rust уже может занять десятки минут (и даже если вы не с 0 собираете это дольше). У C#/Java по лучше дела но старт и прогрев VM это отдельная боль.


                                                  3) Какая связь между объявлением класса и связанностью кода?

                                                  Прямая! От класса могут унаследоваться, у класса более сложный интерфейс (функции, данные), это тяжёлая абстракция которая увеличивает связанность, отчасти по этому в Rust и Go от них отказались.


                                                  4) Не уверен, что правильно понял вашу мысль про конвейер, но если вы о чём-то вроде этого, то я совершенно не понимаю, что вы понимаете под связанностью кода. Потому что подобные подходы как раз и предназначены для уменьшения этой самой связанности.

                                                  Мы не про паттерны тут вообще.


                                                  Заодно было бы интересно узнать ваше мнение по вот какому вопросу: если динамическая типизация настолько хороша, почему во многих ДТ-языках (python, php, ruby) тренд на добавление возможностей СТ?

                                                  А ещё TS для JS. Во многом это связано с тем что в эти языки переходят люди с СТ языков и чувствуют дискомфорт, пишут плохой код и т.д. эти подпорки позволяют им писать в их стиле более или менее комфортно. Другая важная вещь это то что уже упоминалось тут — навигация по коду.
                                                  Если у библиотеки описан СТ интерфейс то при наличии хорошего редактора этим пользоваться становится удобнее и нужно меньше смотреть в документацию и исходники.
                                                  К счатью это опционально и можно употреблять только там где от этого выгода привышает минусы.


                                                  Ну и если вы меня записываете в противники СТ — то нет, я сам этим пользуюсь, пишу на C/C++ и активно ковыряю Rust (а до этого много писал на C# и Java).

                                                    0
                                                    Вывод помогает при работе с перменными внутри функций, но боилерплейт остаётся при объявлении интерфейсов (это и функции если что).

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

                                                    Вопросс во времени, запуск Python, JS, PHP почти моментальный, сборка большого проекта с шаблонами на C++/Rust уже может занять десятки минут

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

                                                    От класса могут унаследоваться, у класса более сложный интерфейс

                                                    В js/python/php тоже есть классы, а в С их нету. Из ваших сообщений я всё ещё не могу уяснить, какие связи между утверждениями «X — компилируемый/интерпретируемый», «в X динамическая/статическая типизация» и «в X используются классы».
                                                    Опять же, не могу не отметить, что не вижу «прямой» связи между классами и связанностью, если под связанностью мы понимаем это.
                                                      0
                                                      при этом большой проблемы в указании типов аргументов/результата я не вижу
                                                      Я лично вижу большую проблему делать это несколько тысяч раз за пару месяцев.

                                                      Лично мне комфортнее видеть сигнатуру функции, чтобы понимать её ограничения и область применения
                                                      Ну а мне, если я читаю уже написанный код, нужно понимать не как функция _может_ использоваться, а как она _используется_ в этой конкретной программе. Типы с этим слабо помогают, хорошо помогает граф вызывов (call graph).
                                                        +2
                                                        хорошо помогает граф вызывов (call graph)

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


                                                        А вот статические типы как раз помогают его строить.

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

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

                                                          нужно понимать не как функция _может_ использоваться, а как она _используется_ в этой конкретной программе

                                                          Вот как раз в этом вопросе статическая типизация помогает гораздо лучше — нет нужды заглядывать в исходники функции, потому что ваша IDE легко подскажет вам, результат какого типа она возвращает.
                                                            0
                                                            Я не знаю, какой код вы пишете, что вам приходится несколько тысяч раз за пару месяцев менять сигнатуры функций.
                                                            Не менять, а писать. ~500 функций за пару месяцев разработки либы с нуля => несколько тысяч переменных в их сигнатурах.
                                                            По моим субъективным ощущениям, менять сигнатуру приходится довольно редко.
                                                            По моим тоже.
                                                            нет нужды заглядывать в исходники функции
                                                            Что и как конкретно она делает тогда как узнать? Какие другие функции она вызывает? В моём понимании чтение кода — и есть заглядывание в исходники.
                                                              0
                                                              Поясню свою мысль.
                                                              Допустим, я читаю код, в котором есть вызов функции foo().

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

                                                              В худшем случае, мне таки придётся лезть в кишки foo() и разбираться в том, как она работает и что возвращает.

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

                                                              Само собой, этот недостаток можно сгладить, например, подходящим наименованием — вроде методов с ! и ? в ruby. Но это всегда останется лишь соглашением, которое требует определённой работы, а при наличии статической типизации всё это есть у вас из коробки.
                                                                0
                                                                ведь я знаю главное — её результат
                                                                Я не могу сказать, что я знаю результат, только посмотрев на сигнатуру. Я могу предположить, а предположение может быть неверным. Чтобы знать, нужно читать, что функция делает (или доки, если есть). Как-то так.

                                                                Просто предполагашки на длинной дистанции как правило рано или поздно приводят к серьёзным ошибкам. Поэтому почитать начинку вызываемых функций/методов лишней когнитивной нагрузкой я не считаю.
                                                                  +2

                                                                  Возвращаясь к моему примеру, рассмотрим функцию dest из пакета vinyl-fs, представив что документация на неё куда-то делась.


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


                                                                  Просто предполагашки на длинной дистанции как правило рано или поздно приводят к серьёзным ошибкам.

                                                                  Только если тот, кто писал код, не ставил целью написать простую в понимании программу.


                                                                  В хорошем коде назначение большинства функций должно быть очевидно.

                                                            +4
                                                            Я лично вижу большую проблему делать это несколько тысяч раз за пару месяцев.

                                                            Я лично вижу большую проблему этого не делать. Это ж самому полезно: написал foo :: String -> Int -> Maybe Float, и не нужно помнить, где что ожидается, где что возвращается.


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

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

                                                          +3
                                                          Вопросс во времени, запуск Python, JS, PHP почти моментальный, сборка большого проекта с шаблонами на C++/Rust уже может занять десятки минут (и даже если вы не с 0 собираете это дольше). У C#/Java по лучше дела но старт и прогрев VM это отдельная боль.

                                                          Язык со статической типизацией вполне себе может иметь репл, в котором запуск приложения тоже почти моментальный (просто выполняется без оптимизаций).

                                                    +1
                                                    он в СТ 80% времени тратит на рефакторинг и только 20% на написание нового кода

                                                    Очень забавно, но иногда мне кажется, что я 100% времени трачу на рефакторинг: мне часто дают задачи взять кусок кода написанный ранее левой пяткой и сделать его быструю и надежную версию к тому же добавив новый функционал.
                                                    Но свой код я рефачу достаточно редко:)

                                                    Берёте и добавляете, Python или хорошый JS читается как книга

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

                                                    Берёте и меняете… если вы меняете поведение функции то вы либо создаёте новую функцию с новым именем либо (что геморойно и зачем такое вообще?) бегаете поиском по проекту и меняете поведени, но вообще это моветон такое делать не соглосовав с коллегами.


                                                    Ну вот допустим стал ты перформенс инженером, сидишь профилируешь код: видишь какие-то умники наслушались лекций про ФП и везде возвращают значения обернутые в Optional и один такой метод горячий прям реально нагружает GC, т.к. где ФП, а где джава. Идешь и меняешь метод, он, скажем, теперь возвращает не Optional, а String но может и null теперь вернуть. Коллега, который этот метод написал давно уволился, а те кто им пользовались половина либо уволились либо стали менеджерами и забыли как программировать. Другие же трогали его год назад и уже забыли где и почему. А тимлид хочет ступеньку на графике таймингов уже завтра. Вот в такой ситуации без типов будет очень больно.
                                                      –2
                                                      Очень забавно, но иногда мне кажется, что я 100% времени трачу на рефакторинг: мне часто дают задачи взять кусок кода написанный ранее левой пяткой и сделать его быструю и надежную версию к тому же добавив новый функционал.
                                                      Но свой код я рефачу достаточно редко:)

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


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

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


                                                      (на остальное по позже отвечу)

                                                        0
                                                        Ну вот допустим стал ты перформенс инженером, сидишь профилируешь код: видишь какие-то умники наслушались лекций про ФП и везде возвращают значения обернутые в Optional и один такой метод горячий прям реально нагружает GC, т.к. где ФП, а где джава. Идешь и меняешь метод, он, скажем, теперь возвращает не Optional, а String но может и null теперь вернуть. Коллега, который этот метод написал давно уволился, а те кто им пользовались половина либо уволились либо стали менеджерами и забыли как программировать. Другие же трогали его год назад и уже забыли где и почему. А тимлид хочет ступеньку на графике таймингов уже завтра. Вот в такой ситуации без типов будет очень больно.

                                                        Не очень корректный пример так как Optional это свойство СТ языков и в ДТ у вас такой вопросс просто не встанет.

                                                          0
                                                            –1

                                                            Ну это не часть языка а с боку прикреплённая конструкция, работающая только в runtime.

                                                              0

                                                              Вы так говорите, как будто в каком-то другом языке Optional является частью именно языка!

                                                            0
                                                            Хочешь сказать, что не бывает такого что в проектах на питоне заменяют медленную структуру другой более быстрой, но с чуть чуть другим апи?)
                                                    0

                                                    Ковариантность и контравариантность вообще нормально/внятно хотя бы в одном ЯП сделаны? В каком?

                                                      0
                                                      По идее должно хорошо и просто работать в ФП языках так как там нет мутаций. Если разрешать мутации, то сразу много выплывает тонких кейсов, так что всё равно придется какие-то корректные вещи запрещать.
                                                      Ну вот например удалять из коллекции можно всегда — это не приведёт к проблемам типизации. Это на самом деле чудовищная тема если начать её копать, особенно если отойти от абстрактных вещей к конкретным. Я советую для разминки потратить где-то пол часа жизни что бы разобраться почему метод HashMap.get в Rust принимает не тип ключа K, а новый тип Q, который такой что
                                                      K: Borrow<Q> 
                                                      и
                                                      Q: Hash + Eq

                                                      doc.rust-lang.org/std/collections/struct.HashMap.html#method.get
                                                        0
                                                        По идее должно хорошо и просто работать в ФП языках так как там нет мутаций.

                                                        Но там и сабтайпинг-полиморфизма нет (почти), поэтому вопрос о списках из базовых классов не имеет смысла. Хотя скала это вроде как совмещает, надо спросить у скалистов, как у них там.


                                                        Впрочем, ради полноты — в обмазанном типами ФП есть своя ко(нтра)вариантность на уровне параметрического полиморфизма. Пример, отсюда: если даны


                                                        g :: ((forall a. a -> a) -> R) -> S
                                                        f :: (Int -> Int) -> R

                                                        то должно ли g f типизироваться?

                                                        0

                                                        Встречный вопрос: а что вы понимаете под "внятно" и в каких языках они сделаны невнятно?

                                                          0
                                                          Eiffel?
                                                          0

                                                          Заметил случайно...


                                                          Правильный же способ, минимизирующий логические ошибки, это создать три класса: RemovedColumn, AddedColumn и TypeChanged. Стоит унаследовать их от общего класса SchemaDiff, чтобы было удобнее обрабатывать их вместе.

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

                                                          НЕТ. Это никакой не АТД.


                                                          Главное отличие от АТД — в том, что у АТД внешняя расширяемость по операциям, а у подобных иерархий классов — по вариантам данных.


                                                          Иными словами, если у нас есть тип RemovedColumn | AddedColumn | TypeChanged, то мы в других модулях можем добавить любое число разбирающих этот тип функций, и в некоторых языках компилятор даже проверит, что мы не пропустили ни одного варианта. Но при этом в другом модуле мы не можем добавить конструктор TableAdded для этого типа.


                                                          А если у нас есть абстрактный класс SchemaDiff, то в другом модуле мы можем добавить наследника под названием TableAdded, но при этом если в базовом классе была только toString — то и никаких других операций определить над типом SchemaDiff не получится.

                                                            0
                                                            Обратите внимание на аккуратность формулировки «здесь я фактически руками реализую алгебраический тип данных» целых две оговорки в предложении.
                                                            Понятно что в Java нет АТД и нет синтаксических возможностей его выразить (но кстати в новых версиях там есть sealed классы, что бы нельзя было объявлять новых наследников, но я решил писать для статьи на Java 11). Здесь происходит такого же рода симуляция, как например люди делали в свое время ООП на чистом С, тоже корявое и рукописное.

                                                            Действительно классы и АТД удобно расширяются в разные стороны, но здесь мы ничего не расширяем и отличие как бы не заметно. Я думал об этом написать, но тогда пришлось бы объяснять double dispatch и всякие tagless final. Но не стал, т.к. во-первых статья уже огромная, во-вторых её тема это не «пишем ФП на Java». Ну и цель примера показать как можно убрать логику в декларации, а то что оно похоже на АТД в некотором роди приятный бонус.
                                                            0
                                                            Извините, если мои слова покажутся еретическими, но мне кажется, что со статической типизацией мы привносим состояния для данных в явном виде, т.е. наши функции переводят данные из одного состояния (типа) в другой. Этакий КА навыворот.

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

                                                            Самое читаемое