Abnormal programming
Programming
7 January

Субъективное видение идеального языка программирования

Дальнейший текст — моя точка зрения. Возможно, она позволит кому-то по-новому взглянуть на дизайн языков программирования или увидеть какие-то преимущества и недостатки конкретных фич. Я не буду лезть в частные подробности типа "в языке должна быть конструкция while", а просто опишу общие подходы. P.S. У меня когда-то была идея создать свой язык программирования, но это оказалось довольно сложным процессом, который я пока не осилил.


Влияние предыдущего опыта


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


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


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


Мой опыт: когда-то я начинал с паскаля, впоследствии познакомился с Java, Kotlin, C++, Python, Scheme, а основными языком считаю Scala. Как и в вышеописанном случае, мой "идеальный" язык имеет много общего со Scala. По крайней мере, я отдаю себе отчёт в этом сходстве)


Влияние синтаксиса на стиль кода


"Писать на фортране можно на любом языке"


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


Python:


filtered_lst = [elem for elem in lst if elem.y > 2]
filtered_lst = list(filter(lambda elem: elem.y > 2, lst))

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


Scala:


val filteredLst = lst.filter(_.y > 2)

Имхо, это близко к идеалу. Ничего лишнего. Если бы в питоне можно было объявлять лямбды более коротким способом, хотя бы it => it.y > 2, то генераторы списков оказались бы не очень нужными.


Самое интересное, что подход как в скале хорошо масштабируется в цепочку вызовов типа lst.map(_.x).filter(_>0).distinct() Мы читаем и пишем код слева направо, элементы идут по цепочке преобразований тоже слева направо, это удобно и органично. Вдобавок, среда разработки по коду слева может давать адекватные подсказки.


В питоне в строчке [elem for elem in среда разработки до последнего не подозревает, какой же тип у элемента. Большие конструкции приходится читать справа налево, из-за чего эти самые большие конструкции в питоне обычно не пишут.


... = set(filter(lambda it: it > 2, map(lambda it: it.x, lst))))

Это же ужасно!


Подход с lst.filter(...).map(...) в питоне мог бы существовать, но он убит на корню динамической типизацией и неидеальной поддержкой сред разработки, которые далеко не всегда догадываются о типе переменной. А подсказать, что в numpy есть функция max — всегда пожалуйста. Поэтому и дизайн большинства библиотек подразумевает не объект с кучей методов, а примитивный объект и кучу функций, которые его принимают и что-то делают.


Ещё один пример, уже на java:


int x = func();
final int x = func();

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


let x = 1;
let mut x = 1;

Получается, что синтаксис языка реально важен, и должен быть по возможности простым и лаконичным. Язык должен изначально создаваться под часто используемые фичи. Антипримером можно назвать С++, где по историческим причинам определение класса раскидывается по паре файлов, а объявление простой функции может не влазить в строчку благодаря словам типа template, typename, inline, virtual, override, const, constexpr и не менее "коротким" описаниям типов аргументов.


Статическая типизация


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


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


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


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


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


Unit, void и отличия функции от процедуры


В паскале/delphi есть разделение на процедуры (не возвращающие значений) и функции (что-то возвращающие). Но никто не запрещает нам вызвать функцию, а возвращаемое значение не использовать. Хм. Так в чём же разница между функцией и процедурой? Да ни в чём, это инерция мышления. Своеобразное легаси, переползшее в Java, С++ и ещё кучу языков. Вы скажете: "есть же void!" Но проблема в том, что void в них это не совсем тип, и если залезть в шаблоны или дженерики, то это отличие становится заметным. Например, в Java HashSet<T> реализовано как HashMap<T, Boolean>. Тип boolean — просто заглушка, костыль. Он там не нужен, в HashMap не требуется значение, чтобы сказать, что ключа нет. В С/С++ тоже есть нюансы с sizeof(void).


Так вот, в идеальном языке должен быть тип Unit, который занимает 0 байт и принимает только одно значение (не важно какое, оно одно, и если у вас есть Unit, то это оно). Этот тип должен быть полноценным типом, и тогда компилятор станет проще, а дизайн языка красивее и логичнее. В идеальном языке можно будет реализовать HashSet<T> как HashMap<T, Unit> и не иметь никакого оверхеда на хранение ненужных объектов.


Кортежи


У нас есть ещё кое-какое историческое наследие, пришедшее, наверно, из математики. Функции могут принимать много значений, а возвращают только одно. Что за ассиметрия?! Так сделано в большинстве языков, что приводит к следующим проблемам:


  • Функции с переменным числом аргументом требуют специального синтаксиса — усложняется язык. Сделать универсальную функцию-прокси становится сложнее.
  • Чтобы возвратить сразу несколько значений, приходится объявлять специальную структуру или передавать изменяемые аргументы по ссылке. Это неудобно.

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


Есть некоторые шаги навстречу типа std::tuple в с++, но как мне кажется, подобное должно быть не в стандартной библиотеке, а существовать прямо в системе типов языка и записываться, например, как (T1, T2). (кстати, на тип Unit можно смотреть как на кортеж без элементов). Сигнатура функции должна описываться как T => U, где T и U — какие-то типы. Возможно, кто-то из них Unit, возможо, кортеж. Честно говоря, я удивлён, что в большинстве языков это не так. Видимо, инерция мышления.


Раз уж мы можем возвращать Union, можно полностью отказаться от разделения выражение/инструкция и сделать, чтобы в языке любая конструкция что-то возвращала. Подобное уже реализовано в относительно молодых языках типа scala/kotlin/rust — и это удобно.


val a = 10 * 24 * 60 * 60
val b = {
    val secondsInDay = 24 * 60 * 60
    val daysCount = 10
    daysCount * secondsInDay
}

Enums, Union и Tagged Union


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


Во-первых, язык должен поддерживать легковесное объявление типов-перечислений. Желательно, чтобы в рантайме они превращались в обычные числа и от них не было никакой лишней нагрузки. А то получается всякая боль и печаль, когда одни функции возвращают 0 при успешном завершении или код ошибки, а другие функции возвращают true (1) при удаче или false (0) при фейле. На надо так. Объявление типа перечисления должно быть насколько коротким, чтобы программист прямо в сигнатуре функции мог написать, что функция возвращает что-то из success | fail или ok|failReason1 | failReason2.


Кроме того, оказываются очень удобными типы-перечисления, которые могут содержать значения. Например, ok | error(code) или Pointer[MyAwesomeClass] |null Такой подход позволит избежать кучи ошибок в коде.


В общем виде это можно назвать типами-суммами. Они содержат одно из нескольких значений. Разница между Union и Tagged Union состоит в том, что мы будем делать в случаях совпадающих типов, например int | int. С точки зрения простого Union int | int == int, так как у нас в любом случае инт. В общем-то с union в си так и получается. В случае с int | int tagged union ещё содержит информацию, какой у нас int — первый или второй.


Маленькое отступление


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


List(x) = Unit | (x, List(x))

Ну почти как списки в лиспе.
Если заменить тип-сумму на сложение (неспроста же он так называется), кортеж интерпретировать как произведение, то получится:


f(x) = 1 + x * f(x)

Ну или другими словами, f(x) = 1 + x + x*x + x*x*x + ..., а с точки зрения типов-произведений (кортежей) и типов-сумм это выглядит как


List(x) = Unit | (x, Unit) | (x, (x, Unit)) | ...  = Unit | x | (x, x) | (x, x, x) | ...

Cписок типа x = это пустой список, или один элемент x, или кортеж из двух, или ...


Можно сказать, что (x, Unit) == x, аналогией в мире чисел будет x * 1 = x, так же (x, (x, (x, Unit))) можно превратить в (x, x, x).


К сожалению, из этого следует, что теоремы для обычных чисел можно выразить на языке типов, и, подобно теоремам, которые не всегда легко доказываются (если вообще доказываются), взаимоотношения типов тоже могут быть довольно сложными. Возможно, поэтому в реальньных языках такие возможности сильно ограничены. Впрочем, это не является непреодолимым препятствием — например, язык шаблонов в С++ является Тьюринг-полным, что не мешает компилятору переваривать адекватно написанный код.


Короче, типы-суммы в языке нужны, и они нужны прямо в системе типов языка, чтобы нормально сочетаться с типами-произведениями (кортежами). Там получится целый простор для преобразований типа (A, B | C) == (A, B) | (A, C)


Константы


Возможно, это звучит неожиданно, но неизменяемость можно понимать по разному. Я вижу аж четыре степени изменяемости.


  1. изменяемая переменная
  2. переменная, которую "мы" не можем менять, но вообще-то она изменяемая (например, в функцию передают контейнер по константной ссылке)
  3. переменная, которую инициализировали и она больше не изменится.
  4. константа, которую можно найти прямо во время компиляции.

Разница между пунктами 2 и 3 не совсем очевидна, приведу пример: допустим, в С++ нам в объект передали указатель на константную память. Если мы где-то внутри класса сохраним этот указатель, то у нас нет никаких гарантий, что в течение жизни объекта содержимое памяти по указателю не изменится.
В некоторых случаях нам нужен именно третий тип неизменяемости — например, при чтении объекта из нескольких потоков или при вычислении чего-то, основанного на свойствах полученного объекта. Именно третий тип неизменяемости позволит компилятору проводить какие-то хитрые оптимизации. Пример использования — final поле в java.


Лично мне кажется, что нюансы с изменяемостью 1-2 типа можно решить с помощью интерфейсов и отсутствующих геттеров/сеттеров. Например, у нас есть неизменяемый объект, который содержит указатель на изменяемую память. Вполне возможно, что мы захотим иметь несколько "интерфейсов" для использования объекта — и тот, который не даст менять только объект и тот, который, например, закроет доступ к внешней памяти.
(Как нетрудно догадаться, тут на меня повлияли jvm языки, в которых нет слова const)


Вычисления, производимые во время компиляции — тоже очень интересная тема. На мой взгляд, самый красивый подход используется в D. Пишется что-то вроде static value = func(42); и самая обычная функция явно вычисляется при компиляции.


Фишечки котлина


Если кто-то использовал gradle, то, возможно, при взгляде на неработающие build файлы вас посещала мысль типа "wtf? Что мне делать?"


android {
    compileSdkVersion 28
}

Это просто код на языке Groovy. Объект android просто принимает замыкание { compileSdkVersion 28}, и где-то в дебрях андроид-плагина этому замыканию присваивается объект, в контексте которого реально будет запущено наше замыкание. Проблема тут в динамичности языка groovy — среда разработки не подозревает о том, какие поля и методы возможны в нашем замыкании и не может подсветить ошибки.


Так вот, в котлине есть хитрые типы, и это можно было бы реализовать как-то так


class UnderlyingAndroid(){
     compileSdkVersion: Int = 42
}

fun android(func: UndelyingAndroid.() -> Unit) ....

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


Можно сказать, что это всё синтаксический сахар и вместо этого писать так:


android { it =>
    it.compileSdkVersion = 28
}

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


Аналогично extension методы. Синтаксический сахар, но довольно удобный. Совсем необязательно быть автором класса, чтобы добавить к нему очередной метод. А ещё их можно вкладывать в области видимости чего-нибудь и таким образом не засорять глобальную область. Ещё одно интересное применение — можно навешивать эти методы на существующие коллекции. Например, если коллекция содержит объекты типа T, которые поддерживают сложение с самими собой, то можно добавить коллекции метод sum, который будет только в том случае, если T это позволяет.


Call-by-name семантика


Это опять же синтаксический сахар, но это удобно, и вдобавок позволяет писать ленивые вычисления. Например, в коде типа map.getOrElse(key, new Smth()) второй аргумент принимается не по значению, а потому новый объект будет создан только если в таблице нет ключа. Аналогично, функции типа assert(cond, makeLogMessage()) выглядят намного приятнее и удобнее.


Вдобавок, никто не заставляет компилятор делать именно анонимную функцию — например, можно заинлайнить функцию assert и тогда это превратится просто в if (cond) { log(makeLogMessage())}, что тоже неплохо.


Я не скажу, что это must have фича языка, но она явно заслуживает внимания.


Ко-контр-ин-нон-вариантность шаблонных параметров


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


Явные неявные преобразования


С одной стороны, неявные преобразования от одного типа к другому могут привести к ошибкам. С другой, опыт того же котлина показывает, что писать явные преобразования оказывается довольно уныло. Имхо, в идеале язык должен позволять явно разрешать неявные преобразования, чтобы они использовались осознанно и только там где необходимо. Например, то же преобразование из int в long.


Где хранить типы объектов?


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


Значения, ссылки, указатели


Язык должен скрывать от программиста подробности реализации. В С++ при написании шаблонов возникают проблемы, так как T в шаблоне может оказаться каким-нибудь неожиданным типом. Это может быть значение, указатель, ссылка или rvalue-ссылка, некоторые приправляются словом const. Не могу сказать, как надо сделать, но точно вижу, как делать не надо. Что-то близкое к идеалу по удобству есть в Scala и Kotlin, где примитивные типы "притворяются" объектами, так что всё с чем мы работаем выглядит однообразно и не нагружает мозг программиста и синтаксис языка.


Минимум сущностей


Вот чем мне не нравится С# — в язык втащили кучу всего, это всё как-то странно сочетается и повышает сложность языка. (Я могу сильно ошибаться в деталях, так как на С# я писал очень давно и только под Unity) Например, там есть поля класса, проперти и методы. 3 сущности! Они друг с другом не очень сочетаются, можно объявить несколько методов с одним именем, но разной сигнатурой, но почему-то нельзя объявить проперти с тем же именем. Или если интерфейс требует чтобы было проперти, то нельзя в классе просто объявить поле — это должно быть именно проперти.


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


Ещё пример — слово inline в C++/Kotlin. Не стоит его тащить в язык! И там и там слово inline меняет логику компиляции и исполнения кода, люди начинают писать inline не ради собственно инлайна, а ради возможностей писать функцию в хедере (С++) или делать хитрый return из вложенной функции как из вызывающей (kotlin). Потом в языке появляются forced_inline__, noinline, crossinline, влияющие на какие-нибудь нюансы и ещё более усложняющие язык. Мне кажется, язык должен быть максимально гибким и простым, а те же inline могут быть аннотациями, которые не влияют на логику исполнения кода и лишь помогают компилятору.


Макросы


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


Функции внутри функций


Плоская структура уныла. Если что-то используется только в одном-двух местах, то почему бы не сделать это максимально локально — например, разрешить объявлять внутри функций какие-то локальные функции или классы. Это же удобно: не засоряется пространство имён, при удалении кода функции заодно удаляются и её "внутренние" подробности.


Substructural type system


Можно реализовать систему типов, на использование которых накладываются ограничения. Например, переменную можно использовать только один раз или, например, не более одного.
Зачем это может пригодиться? Move-семантика и идея владения основана на том, что отдать владение объектом можно только один раз. Кроме того, всякие объекты с внутренним состоянием могут подразумевать определённый сценарий работы. Например, мы сначала открываем файл, что-то читаем/пишем, а потом закрываем обратно. Сейчас состояние файла лежит на совести программиста, хотя действия с ним (теоретически) можно запихнуть в систему типов и избавиться от части ошибок.


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


Зависимые типы


С одной стороны, они выглядят очень интересно и многообещающе, но я не представляю, как их реализовать. Конечно, мне бы хотелось иметь хитрые типы, например, список из не менее чем одного элемента или число, которое делится на 5, но не делится на 3, но я плохо представляю, как подобное можно доказывать в достаточно сложной программе.


Сборка


Во-первых, язык должен уметь работать и без стандартной библиотеки. Во-вторых, библиотека должна состоять из отдельных кусочков (возможно, с зависимостями между ними), чтобы при желании можно было включить только часть из них. В третьих, в современном языке должна быть удобная система сборки (в С++ боль и печаль).
Функции, переменные и классы должны использоваться для описания хода вычисления, это не то, что надо запихивать в бинарник. Для экспорта наружу можно как-нибудь аннотировать необходимые кусочки, но всё остальное должно быть отдано компилятору и пусть он преобазует код как хочет.


Выводы


Итак, на мой взгляд, в идеальном языке программирования должны быть:


  • мощная система типов, с самого начала поддерживающая
    • типы-объединения и кортежи
    • ограничения на шаблонные параметры и их взаимоотношения друг с другом
    • возможно, экзотику типа линейных или даже зависимых типов.
  • удобный синтаксис
    • лаконичный
    • располагающий писать в декларативном стиле и использовать константы.
    • унифицированный для типов по значению и по указателю (ссылке)
    • максимально простой и гибкий
    • учитывающий возможности ide и рассчитаный на удобное взаимодействие с ней.
  • чуточку синтаксического сахара типа extension-методов и ленивых аргументов функции.
  • возможность перенести часть вычислений на этап компиляции
  • макросы, работающие прямо с AST
  • удобные сопутствующие инструменты типа системы сборки

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


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


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


+50
18.6k 85
Comments 318
Top of the day