Хабрахабр

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

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

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

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

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

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

Как и в вышеописанном случае, мой "идеальный" язык имеет много общего со Scala. Мой опыт: когда-то я начинал с паскаля, впоследствии познакомился с Java, Kotlin, C++, Python, Scheme, а основными языком считаю 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))))

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

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

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

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

Кортежи

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

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

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

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

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

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

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

Вообще говоря, если взять кортежи и типы-суммы (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
}

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

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

class UnderlyingAndroid(){ compileSdkVersion: Int = 42
} fun android(func: UndelyingAndroid.() -> Unit) ....

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

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

android { it => it.compileSdkVersion = 28
}

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

Синтаксический сахар, но довольно удобный. Аналогично 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. Потом в языке появляются forced_inline__, noinline, crossinline, влияющие на какие-нибудь нюансы и ещё более усложняющие язык. И там и там слово inline меняет логику компиляции и исполнения кода, люди начинают писать inline не ради собственно инлайна, а ради возможностей писать функцию в хедере (С++) или делать хитрый return из вложенной функции как из вызывающей (kotlin). Мне кажется, язык должен быть максимально гибким и простым, а те же inline могут быть аннотациями, которые не влияют на логику исполнения кода и лишь помогают компилятору.

Макросы

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

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

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

Substructural type system

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

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

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

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

Сборка

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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть