Это расшифровка выступления Арака (Andreas Rumpf, создатель языка Nim — прим. пер.) на NimConf2021, случившегося 26 июня (вот запись на youtube и слайды на github). Пьетро Петерлонго адаптировал текст к публикации в блоге, а Арак дополнительно проверил его.
Zen of Nim
Копирование плохого дизайна — так себе дизайн.
Если компилятор не может рассуждать о коде, то и программист не может.
Не стой на пути у программиста.
Перенеси работу на этап компиляции: программы запускаются гораздо чаще, чем компилируются.
Настраиваемое управление памятью.
Лаконичный код не мешает читабельности, он ей способствует.
(Задействовать метапрограммирование, чтобы оставить язык компактным).
Оптимизация это специализация: если вам нужно больше скорости, пишите кастомный код.
Должен быть только один язык программирования для всего. Этот язык — Nim.
Примечание редактора.
В оригинале выступления Zen of Nim был дан в конце (и без нумерации). Здесь мы размещаем его в самом начале, нумеруя, чтобы было проще ссылаться. Дальнейшее раскрытие этих правил происходит в контексте обсуждения языка вообще и не пытается воспроизвести указанный выше порядок. Статья следует за выступлением, от материала на слайдах до расшифровки с минимальной редактурой (это отразилось в неформальном тоне текста).
Содержание
Синтаксис
(знакомит с Nim и объясняет правило 6: лаконичный код способствует читабельности)Умный компилятор
(правило 2: компилятор должен быть способен судить о коде)Возможности метапрограммирования
(даны через правило 1: копирование плохого дизайна — плохой дизайн)Практичный язык
(правило 3: не стой на пути программиста)Настраиваемое управление памятью
(правило 5)Дзен Nim
(итог и обсуждение всех правил; правила 4, 7, 8, 9 освещаются только здесь)
Введение
В этом посте я собираюсь объяснить философию языка Nim и почему Nim может быть полезен для широкого спектра областей применения, таких как:
научные вычисления
игры
компиляторы
разработка операционных систем
написание скриптов
и многих других
«Дзен» в заглавии означает, что мы придём к набору правил (показанных выше), которые направляют разработку языка и его эволюцию, но я буду говорить об этих правилах с помощью примеров.
Синтаксис
Позвольте мне представить Nim через его синтаксис. Я понимаю, что многие из вас, возможно, уже знают этот язык, но чтобы обеспечить плавный вход тем, кто никогда его ранее не видел, я объясню базовый синтаксис и надеюсь придти к интересным выводам.
Nim использует синтаксис, основанный на отступах, вдохновлённый Haskell или Python. Это решение также сочетается с системой макросов.
Применение функции
Nim различает инструкции и выражения, и большинство выражений это применение функции (или «вызов процедуры»). Применение функции использует традиционную математическую запись со скобками: f()
, f(a)
, f(a, b)
.
Но есть и сахар:
Сахар |
Смысл |
Пример |
|
1 |
|
|
|
2 |
|
|
|
3 |
|
|
|
4 |
|
|
|
5 |
|
|
|
6 |
|
|
|
7 |
|
|
|
8 |
|
|
|
По правилам 1 и 2 вы можете опустить скобки. Здесь же есть пример, где и почему это бывает полезно:
spawn
выглядит как ключевое слово, что неплохо, поскольку оно делает что-то особенное;echo
также известен своей необязательностью скобок, потому что обычно вы пишете его для отладки, а значит уже торопитесь всё скорее закончить.Вам доступна запись через точку, и в ней вы тоже можете опускать скобки (3–6).
Правило 7 про строковые литералы:
f
, за которой следует строка без пробелов, это всё ещё вызов, но строка превращается в сырую, что очень сподручно для регулярных выражений, поскольку у них свои представления о том что должен означать бэкслеш.Наконец, в последнем правиле мы видим, что вы можете передать блок кода в
f
с помощью:
. Блок кода обычно это последний аргумент, который вы передаёте функции. Это может быть использовано для создания кастомной инструкцииlock
.
Есть одно исключение для пропуска скобок, в случае, если вы ссылаетесь на f
напрямую: f
не означает f()
.
В конструкции myarray.map(f)
вы не хотите вызывать f
, вместо этого вы просто хотите передать саму f
в map
.
Операторы
В Nim есть бинарные и унарные операторы:
В большинстве случаев бинарные операторы вызываются как
x @ y
, а унарные как@x
.Нет явного различия между операторами и функциями, а также между бинарными и унарными операторами.
func `++`(x: var int; y: int = 1; z: int = 0) =
x = x + y + z
var g = 70
++g
g ++ 7
# оператор в в обратных апострофах обрабатывается как 'f':
g.`++`(10, 20)
echo g # выведет 108
Операторы это просто сахар для функций.
Токен для оператора даётся в обратных апострофах (см.
++
) для определения функции и вызова его, собственно, как функции.
Напомним, что ключевое слово var
указывает на изменяемость:
параметры доступны только для чтения, пока не объявлены как
var
var
означает «передавать по ссылке» (это реализовано как скрытый указатель)
Инструкции vs выражения
Инструкции требуют отступ:
# отступ не требуется для односложных инструкций:
if x: x = false
# отступ требуется для вложенных инструкций:
if x:
if y:
y = false
else:
y = true
# отступ требуется, потому что две инструкции
# следуют за одним условием:
if x:
x = false
y = false
Вы можете также использовать точку с запятой вместо перевода строки, но это очень не характерно для Nim.
Выражения же в действительности не основаны на отступах, так что с ними вы вольны использовать дополнительное белое пространство как захотите:
if thisIsaLongCondition() and
thisIsAnotherLongCondition(1,
2, 3, 4):
x = true
Это может быть очень удобно для разбивки длинных строк. Как правило, вы можете использовать опциональные отступы после операторов, скобок и запятых.
Наконец, инструкции if
, case
и подобные также доступны в виде выражений, так что они могут возвращать значение.
В качестве простого примера, чтобы закончить этот раздел, вот законченная программа на Nim, демонстрирующая ещё немного синтаксиса. Если вы знакомы с Python, вам должно быть несложно это прочитать:
func indexOf(s: string; x: set[char]): int =
for i in 0..<s.len:
if s[i] in x: return i
return -1
let whitespacePos = indexOf("abc def", {' ', '\t'})
echo whitespacePos
Nim использует статическую типизацию, поэтому за параметрами следуют типы: входной параметр
s
имеет типstring
;x
имеет тип «множество символов»; функция, именуемаяindexOf
, возвращает в конечном итоге целочисленное значение.Вы можете итерироваться по индексу строки с помощью цикла
for
, цель здесь — найти позицию первого символа внутри строки, совпадающего с одним из данного множества.При вызове функции мы конструируем множество символов, условно отвечающих критерию «пробел», с помощью фигурных скобок (
{}
)
Поговорив немного о синтаксисе, мы можем сформулировать наше первое правило дзен:
Лаконичный код не мешает читабельности, он ей способствует.
Как вы можете видеть в крошечном примере выше, просматривать глазами и читать код довольно легко, потому что мы попросту убрали символы, которые не несут особой смысловой нагрузки, такие как фигурные скобки для блоков или точки с запятыми для завершения инструкций. Это масштабируемый принцип, и в длинных программах он действительно полезен когда вам надо просмотреть меньше кода, чтобы понять как он устроен или что он делает (и не слишком вдаваясь при этом в детали).
Типичный аргумент против: «синтаксис слишком сжатый, это нечитабельно, и всё что вы хотите сделать это сократить усилия по набору кода»; для меня это пример непонимания, дело не в экономии нажатий или усилий по набору, а в экономии усилий в тот момент, когда вы смотрите на получившийся код. Программы гораздо чаще читают, чем пишут, и когда вы их читаете, очень уместно, если они короче.
Умный компилятор
Второе правило Nim:
Компилятор должен быть способным рассуждать о коде.
Это означает, что мы хотим:
Структурное программирование.
Статическую типизацию!
Статическое связывание!
Отслеживать сайд-эффекты.
Отслеживать исключения.
Ограничения изменяемости (здесь наш враг это разделяемое изменяемое состояние, но если состояние ни с кем не разделяется, никаких проблем делать его изменяемым: мы хотим иметь возможность делать это наверняка).
Типы данных, основанные на значениях (про алиасинг очень сложно рассуждать!)
Дальше мы увидим в деталях, что всё это значит.
Структурное программирование
Задача следующего примера — посчитать слова в файле (заданном через параметр filename
типа string
) и вернуть таблицу подсчёта строк, чтобы в итоге там была запись на каждое слово и как часто слово появляется в тексте.
import tables, strutils
proc countWords(filename: string): CountTable[string] =
## Counts all the words in the file.
result = initCountTable[string]()
for word in readFile(filename).split:
result.inc word
# 'result' вместо 'return', никакого не структурного потока управления
Стандартная библиотека Nim, к счастью, уже предлагает нам CountTable
, так что первая строчка нашей proc
это новая таблица подсчета.
result
встроен в Nim и он представляет собой возвращаемое значение, так что вам не нужно писать return result
, что не является примером структурного программирования, потому что return
незамедлительно покидает любую область видимости и возвращает результат. Nim предоставляет возможность использовать инструкцию return
, но мы рекомендуем остерегаться её, поскольку это не является структурным программированием.
В оставшейся части тела proc
мы читаем файл в простой буфер, делим его на отдельные слова и считаем слова с помощью result.inc
Структурное программирование означает, что у вас есть единственная точка входа в блок и единственная точка выхода.
В следующем примере, я выхожу из цикла for
более затейливо, с помощью инструкции continue
:
for item in collection:
if item.isBad: continue
# что нам известно на данный момент?
use item
Для каждого элемента коллекции, если он нас устраивает, мы продолжаем со следующим, либо используем его.
Что я могу знать после инструкции continue? Ну, допустим, я знаю, что элемент подходит.
Почему бы не переписать это используя структурное программирование:
for item in collection:
if not item.isBad:
# что нам известно на данный момент?
# что элемент подходит.
use item
Отступ здесь даёт нам подсказку об инвариантах в нашем коде, так что теперь нам гораздо яснее, что когда я использую item, инвариант говорит нам, что элемент подходит.
Если вы предпочитаете инструкции continue и return, ну и отлично, нет никакого криминала в том, чтобы ими пользоваться, я сам пользуюсь ими в случаях, когда больше ничего не сработает. Но вы должны стараться избегать их. И, что более важно, всё это означает, что мы, вероятно, никогда не добавим более общей инструкции go-to в Nim, потому что go-to ещё больше противоречит парадигме структурного программирования. Мы хотим быть в том положении, которое позволит доказывать всё больше и больше свойств вашего кода, и структурное программирование значительно упрощает механику доказательства, что помогает нам.
Статическая типизация
Ещё одним аргументом в пользу статической типизации является то, что мы действительно хотели бы, чтобы вы использовали собственные типы, определяемые областью применения.
Вот небольшой пример про отделённые строки (distinct string
, distinct
делает новый тип несовместимым с базовым — прим. пер.), а также enum
и set
:
type
SandboxFlag = enum ## что интерпретатор должен разрешать
allowCast, ## разрешить не безопасный 'cast'
allowFFI, ## разрешить FFI
allowInfiniteLoops ## разрешить бесконечные циклы
NimCode = distinct string
proc runNimCode(code: NimCode; flags: set[SandboxFlag] = {allowCast, allowFFI}) =
...
NimCode
хранится какstring
, но этоdistinct string
, то есть особый тип строки со своими правилами.proc runNimCode
выполняет произвольный код на Nim, который вы ей передаёте, и, по сути, это виртуальная машина, выполняющая код. Она может ограничить что возможно, а что нет.Здесь у нас что-то вроде песочницы, и разные свойства, которые вы можете использовать. Например, вы можете сказать: разреши операцию
cast
(allowCast
) или разреши FFI (allowFFI
); последняя опция позволит Nim’у выполнять код в бесконечном цикле (allowInfiniteLoops
).Мы перечислили опции обычном
enum
, после чего мы можем класть их во множество (set
), обозначая таким образом, что каждая опция никак не зависит от других.
Сравним, для примера, код выше с аналогичным кодом на C, где часто прибегают к подобной практике. Но тут мы теряем типобезопасность:
#define allowCast (1 << 0)
#define allowFFI (1 << 1)
#define allowInfiniteLoops (1 << 2)
void runNimCode(char* code, unsigned int flags = allowCast|allowFFI);
runNimCode("4+5", 700); // никто не мешает нам передать 700
Во время вызова
runNimCode
,flags
это просто беззнаковые целые и никто не помешает вам передать значение 700, например, даже если это не имеет никакого смысла.Вам придётся прибегнуть к манипуляции битами (в оригинале «bit twiddling», т. е. акцент на неочевидности манипуляций — прим. пер.), чтобы определить
allowCast
, …allowInfiniteLoops
.
Вы теряете информацию: даже несмотря на то, что программист в этот момент понимает, какое значение является допустимым, всё находится в его голове и не отражено в программе никак, так что компилятор не сможет ничем вам помочь.
Статическое связывание
Мы хотим, чтобы Nim использовал статическое связывание. Вот модифицированный пример «hello world»:
echo "hello ", "world", 99
Что здесь произойдёт? Компилятор перепишет это следующим образом:
echo([$"hello ", $"world", $99])
echo
объявлено так:proc echo(a: varargs[string, `$`]);
$
(операторtoString
в Nim) применяется к каждому аргументу.Мы задействуем здесь перегрузку (оператора
$
в данном случае) вместо динамического связывания (как это было бы, например, в C#)
Это масштабируемая механика:
proc `$`(x: MyObject): string = x.s
var obj = MyObject(s: "xyz")
echo obj # работает
Здесь у меня мой пользовательский тип
MyObject
и я определяю для него оператор$
, чтобы он возвращал только полеs
.Далее, я конструирую
MyObject
со значением«xyz»
.echo понимает как как вывести объекты типа
MyObject
, потому для них определён оператор$
.
Типы данных, основанные на значениях
Мы хотим типы данных, основанные на значениях, потому что это облегчит программе рассуждать о коде. Я уже говорил, что мы хотели бы ограничить разделяемое изменяемое (shared mutable) состояние. Решение, которое всё время упускается из виду в функциональных языках программирования, это ограничить алиасинг, а не изменяемость. Изменяемость это очень прямой, удобный и эффективный способ действия.
type
Rect = object
x, y, w, h: int
# конструктор:
let r = Rect(x: 12, y: 22, w: 40, h: 80)
# доступ к полям:
echo r.x, " ", r.y
# присвоение создаст копию:
var other = r
other.x = 10
assert r.x == 12
То, что присвоение other = r
создаст копию, означает, что никакого запутанного действия со стороны здесь не возникнет, есть только один путь к r.x
и other.x
не создаёт дополнительного доступа по тому же адресу в памяти.
Отслеживать сайд-эффекты
Мы хотим иметь возможность отслеживать сайд-эффекты. В следующем примере цель — подсчитать количество вхождений подстроки в строку.
import strutils
proc count(s: string, sub: string): int {.noSideEffect.} =
result = 0
var i = 0
while true:
i = s.find(sub, i)
if i < 0: break
echo "i is: ", i # ошибка: 'echo' имеет сайд-эффекты
i += sub.len
inc result
Давайте представим, что это не корректный код и в нём есть отладочный echo. Компилятор выдаст жалобу: вы сказали, что proc не имеет сайд-эффектов, но echo их производит, так что вы ошиблись, идите и почините свой код!
Другой аспект языка Nim в том, что несмотря на сообразительность компилятора, который может здорово помочь, иногда вам надо просто закончить свою работу и у вас должна быть возможность ситуативно изменить эту прекрасную установку по-умолчанию.
Так что если я скажу: «окей, я знаю, что здесь появляется сайд-эффект, но мне не важно, потому что это просто код, который я добавил для отладки», вы можете сказать: «эй, преобразуй эту часть кода эффектом noSideEffect
», тогда компилятор останется доволен и ответит: «окей, продолжаем»:
import strutils
proc count(s: string, sub: string): int {.noSideEffect.} =
result = 0
var i = 0
while true:
i = s.find(sub, i)
if i < 0: break
{.cast(noSideEffect).}:
echo "i is: ", i # 'cast', так что продолжаем
i += sub.len
inc result
cast
означает: «Я знаю что я делаю, отстань».
Отслеживать исключения
Мы хотим отслеживать за исключения!
Здесь у меня главная процедура proc main
и я хочу сказать, что она не вызывает никаких исключений, я хочу иметь возможность удостовериться, что я обработал все исключения, которые могут возникнуть:
import os
proc main() {.raises: [].} =
copyDir("from", "to")
# Error: copyDir("from", "to") can raise an
# unlisted exception: ref OSError
Компилятор будет недоволен и скажет: «слушай, это не так, copyDir
может выбросить незарегистрированное исключение, а именно OSError»
. Так что вы скажете: «хорошо, вообще-то я действительно его не отработал», так что я теперь могу указать, что main
вызывает OSError
и компилятор скажет: «да, ты прав!»:
import os
proc main() {.raises: [OSError].} =
copyDir("from", "to")
# скомпилировалось :-)
Мы хотим иметь возможность небольшой параметризации над всем этим:
proc x[E]() {.raises: [E].} =
raise newException(E, "text here")
try:
x[ValueError]()
except ValueError:
echo "good"
Тут у меня дженерик
proc x[E]
(E
это обобщённый тип) и я говорю: «что бы ты не направил вx
, это то, что я хотел бы здесь выбросить как исключение»Потом я ввожу этот
x
с исключениемValueError
и компилятор счастлив!
Я был действительно удивлён тому, что оно уже работает из коробки. Когда я придумал этот пример, я был абсолютно уверен, что компилятор сломается. Но он справляется с этой ситуацией очень хорошо без каких-либо дополнительных действий, и я думаю, что причина в том, что кто-то здесь помог и уже починил несколько багов.
Ограничения изменяемости
Я собираюсь показать и объяснить, что делает экспериментальный ключ strictFuncs
:
{.experimental: "strictFuncs".}
type
Node = ref object
next, prev: Node
data: string
func len(n: Node): int =
var it = n
result = 0
while it != nil:
inc result
it = it.next
Здесь описан тип
Node
, который представляет из себяref object
, егоnext
иprev
это указатели на объекты того же типа (это двусвязный список). Так же в нём есть полеdata
типаstring
.Дальше идёт функция
len
, которая считает количество нод в моём связном списке.Реализация очень прямолинейная: пока мы не упрёмся в
nil
, посчитать текущую ноду и перейти к следующей.
Важным здесь является то, что с помощью strictFuncs мы сообщаем компилятору, что объекты, доступные через аргументы теперь глубоко неизменяемы. Компилятор спокойно воспринимает этот код. А также он спокойно воспринимает и такой пример:
{.experimental: "strictFuncs".}
func insert(x: var seq[Node]; y: Node) =
let L = x.len
x.setLen L + 1
x[L] = y
Я бы хотел
insert
что-нибудь, но этоfunc
, а значит она строго ограничивает изменения, которые я делаю.Я буду добавлять в
x
, которая является последовательностью нод, поэтомуx
явно обозначается изменяемой через ключевое словоvar
(а вотy
— не изменяемая).Я могу выставить длину
x
как старую длину плюс один и уже тогда переписать то, что там внутри, замечательно.
Наконец, я по прежнему могу изменять локальное состояние:
func doesCompile(n: Node) =
var m = Node()
m.data = "abc"
Здесь у меня переменная m
типа Node
, но только что созданная. Я могу изменять её и выставить её поле data
, так как она не присоединена к n
. Компилятор доволен.
Семантика такая: «вы не можете изменять то, что доступно через параметр, пока этот параметр не будет явно помечен как var»
.
Вот пример, где компилятор скажет: «Хоба! Вы пытаетесь изменить n, но находитесь в режиме strictFunc, так что не выйдет»
{.experimental: "strictFuncs".}
func doesNotCompile(n: Node) =
n.data = "abc"
Можем поиграть в эту игру и посмотреть насколько он умён.
В этом примере я пытаюсь сыграть с компилятором в напёрстки, чтобы он принял код, но терплю неудачу:
{.experimental: "strictFuncs".}
func select(a, b: Node): Node = b
func mutate(n: Node) =
var it = n
let x = it
let y = x
let z = y # <-- is the statement that connected
# the mutation to the parameter
select(x, z).data = "tricky" # <-- the mutation is here
# Error: an object reachable from 'n'
# is potentially mutated
select
это вспомогательная функция, которая принимает две ноды и просто возвращает вторую.Потом я хочу изменить
n
, но присваиваю её вit
, потомit
вx
,x
вy
и, наконец,y
вz
.После я выбираю
x
илиz
и тогда изменяю полеdata
и перезаписываю строку на значение"tricky"
.
Компилятор скажет вам: «Ошибочка, объект, достижимый через n
потенциально изменяем» и укажет на инструкцию, которая соединяет граф с этим аргументом. Внутри там происходит следующее: у него есть представление в виде абстрактного графа, который задан с условием «каждый строящийся граф является непересекающимся», но в зависимости от тела вашей функции, эти непересекающиеся графы могут соединяться. Когда вы что-то изменяете, изменяется граф, и если он соединён с аргументом, компилятор вам сообщит.
А вот и ещё одно правило:
Если компилятор не может рассуждать о коде, то и программист не может.
Наша цель — чтобы умный компилятор помогал вам. Потому что программировать это сложно.
Возможности метапрограммирования
Следующее правило широко известно в наши дни:
Копирование плохого дизайна — так себе дизайн.
Если вы скажете: «Эй, в языке X есть возможность F, давай тоже её сделаем!», вы скопируете это решение, но не будете знать, хорошее оно или плохое, потому что вы не начали с самого начала.
Например, «В C++ есть выполнение функций во время компиляции, давай тоже сделаем!». Это не причина, чтобы добавить выполнение функций во время компиляции, наша причина (и, кстати, мы сделали совершенно не так как в C++) в следующем: «У нас очень много ситуаций для применения F».
В этом случае F это система макросов: «Нам надо иметь возможность делать блокировки, логирование, ленивые вычисления, типобезопасные Writeln/Printf, декларативный язык для UI, асинхронность и параллельное программирование! И вместо того, чтобы встраивать всё это в язык, давайте сделаем систему макросов.»
Посмотрим, что из себя представляют эти возможности метапрограммирования. Nim предлагает шаблоны (template
) и макросы (macro
) для этих целей.
Шаблоны для ленивых вычислений
template
это просто механизм подстановки. Вот template
, названный log
:
template log(msg: string) =
if debug:
echo msg
log("x: " & $x & ", y: " & $y)
Вы можете читать их как разновидность функции, но принципиальное отличие в том, что они разворачиваются в коде прямо на месте (там, где вы вызываете log
).
Сравните код выше со следующим кодом на C, где log
это #define
:
#define log(msg) \
if (debug) { \
print(msg); \
}
log("x: " + x.toString() + ", y: " + y.toString());
Очень похоже! Причина почему это template
(или #define
) в том, что мы хотим, чтобы сообщение в параметре вычислялось лениво, потому что в этом примере я задействую дорогие операции, такие как конкатенация строк и обращение переменных в строки, и если debug
выключен, этот код не должен быть выполнен. Семантика передачи простого аргумента такая: «выполни это выражение и потом вызови функцию», но потом внутри функции вы обнаруживаете, что debug
выключен и вся эта информация вам не нужна, её вообще можно было не вычислять. Это и есть то, что что нам позволяет template
, поскольку он разворачивается непосредственно при вызове: если debug
равен false
, тогда это сложное выражение из конкатенаций не будет выполняться вообще.
Шаблоны для абстракции потока управления:
Мы можем воспользоваться template
для абстракции потока управления. Если мы хотим инструкцию withLock
, C# предлагает примитив языка, а в Nim вам вообще не нужно встраивать это в язык, вы просто пишете withLock
шаблон и он запрашивает блокировку:
template withLock(lock, body) =
var lock: Lock
try:
acquire lock
body
finally:
release lock
withLock myLock:
accessProtectedResource()
withLock
запрашивает блокировку и в конце отпускает её.внутри куска, где происходит блокировка, целиком выполняется
body
, которое может быть передано вwithLock
через конструкцию с двоеточием и отступами.
Макрос для реализации DSL
Вы можете использовать макросы для реализации DSL.
Пример DSL, описывающий код на html:
html mainPage:
head:
title "Zen of Nim"
body:
ul:
li "A bunch of rules that make no sense."
echo mainPage()
Этот код производит следующее:
<html>
<head><title>Zen of Nim</title></head>
<body>
<ul>
<li>A bunch of rules that make no sense.</li>
</ul>
</body>
</html>
Лифтинг
Вы можете воспользоваться метапрограммированием для лифтинга операций, которые снова и снова требуют их программировать.
Например, у нас есть квадратный корень для чисел с плавающей точкой, и теперь мы хотим операцию квадратного корня, которая будет работать для списка чисел с плавающей точкой. Я мог бы использовать вызов map, но также я могу создать выведенную функцию sqrt
:
import math
template liftFromScalar(fname) =
proc fname[T](x: openArray[T]): seq[T] =
result = newSeq[typeof(x[0])](x.len)
for i in 0..<x.len:
result[i] = fname(x[i])
# пусть sqrt() работает с последовательностями:
liftFromScalar(sqrt)
echo sqrt(@[4.0, 16.0, 25.0, 36.0])
# => @[2.0, 4.0, 5.0, 6.0]
Мы передаём
fname
в шаблон иfname
применяется к каждому элементу последовательности.Конечное имя процедуры (
proc
) такое же, какfname
(sqrt
в этом случае)
Декларативное программирование
Вы можете превратить императивный код в декларативный.
Вот пример, вытащенный из нашего инструментария тестирования:
proc threadTests(r: var Results, cat: Category,
options: string) =
template test(filename: untyped) =
testSpec r, makeTest("tests/threads" / filename,
options, cat, actionRun)
testSpec r, makeTest("tests/threads" / filename,
options & " -d:release", cat, actionRun)
testSpec r, makeTest("tests/threads" / filename,
options & " --tlsEmulation:on", cat, actionRun)
test "tactors"
test "tactors2"
test "threadex"
Это несколько потоков тестов с именами tactors
, tactors2
и threadex
, и каждый из них выполняется в трёх разных конфигурациях: с параметрами по дефолту, дефолт плюс флаг release, дефолт плюс эмуляции локальной памяти потока. Вызов threadTests
требует множество параматров (категория, опции и имя файла), что утомительно, если вы просто копируете их снова и снова, так что здесь я бы хотел сказать: «Это будет тест под названием tactors
, вот этот tactors2
, а вот этот тест будет называться threadex
», и сократив всё это, мы оказываемся на том уровне абстракции, на котором вы действительно собирались работать:
test "tactors"
test "tactors2"
test "threadex"
Можно даже ещё сократить, поскольку все эти вызовы test
немного раздражают. На самом деле я бы хотел сказать следующее:
test "tactors", "tactors2", "threadex"
А вот простой макрос, который это осуществляет:
import macros
macro apply(caller: untyped;
args: varargs[untyped]): untyped =
result = newStmtList()
for a in args:
result.add(newCall(caller, a))
apply test, "tactors", "tactors2", "threadex"
Поскольку он очень прост, он не может довести дело до конца, и от вас требуется сказать apply test
. Этот макрос создаёт список инструкций и каждая инструкция в этом списке на самом деле это вызов выражения, вызывающего этот тест с a
(a
это текущий аргумент, мы итерируемся по всем аргументам).
Детали не так важны, главный инсайт здесь в том, что Nim даёт вам возможность делать подобные вещи. И как только вы немного привыкнете, это окажется удивительно просто.
Типобезоапсные Writeln/Printf
Следующий пример это макрос, дающий нам типобезопасный printf
:
proc write(f: File; a: int) = echo a
proc write(f: File; a: bool) = echo a
proc write(f: File; a: float) = echo a
proc writeNewline(f: File) =
echo "\n"
macro writeln*(f: File; args: varargs[typed]) =
result = newStmtList()
for a in args:
result.add newCall(bindSym"write", f, a)
result.add newCall(bindSym"writeNewline", f)
Как и ранее, мы создаём список инструкций в первой строчке макроса, и далее, итерируясь по каждому аргументу, вызваем функцию, вызывающую
write
.bindSym"write"
биндится сwrite
, но это не один и тот жеwrite
, а перегружающаяся операция, потому что в начале примера стоят три операции write (дляint
,bool
иfloat
), и перегрузка разрешает выбор правильной операцииwrite
.Наконец, в последней строчке макроса стоит вызов функции
writeNewline
, объявленной ранее (она делает отбивку строки)
Практичный язык
Компилятор умён, но:
Не стой на пути у программиста
Существует огромное количество кода, написанного на C++, C и Javascript, который программистам очень нужно переиспользовать. Мы имеем совместимость с C++, C и JavaScript, потому что мы можем скомпилировать Nim в эти языки. Заметьте, что это реализация именно идеи совместимости, философия за этим решением вовсе не в том, что «давайте использовать C++ плюс Nim, потому что Nim не предоставляет некоторых функций, которые нам нужны, чтобы закончить работу». Nim действительно предлагает низкоуровневые возможности, такие как:
bit twiddling,
небезопасная конвертация типов (
cast
),сырые указатели.
Взаимодействие с C++ — это крайняя мера, обычно мы хотим, чтобы вы писали Nim-код и не покидали Nim. Но тут в дело вступает реальный мир и говорит: «Эй, есть куча кода, уже написанного на этих языках, как насчет того, чтобы сделать взаимодействие с ним очень хорошим?».
Мы не хотим, чтобы Nim был одним из многих языков, разные комбинации которых вы используете для реализации вашей системы. В идеале вы используете только Nim, потому что это гораздо дешевле делать. Тогда вы сможете нанимать программистов, которые знают только один язык программирования, а не четыре (или сколько ещё вам может потребоваться).
История с совместимостью зашла так далеко, что фактически мы предоставляем инструкцию emit, с помощью которой вы можете напрямую положить чужеродный код в ваш Nim-код и компилятор соединит их оба в конечном файле.
Вот пример:
{.emit: """
static int cvariable = 420;
""".}
proc embedsC() =
var nimVar = 89
{.emit: ["""fprintf(stdout, "%d\n", cvariable + (int)""",
nimVar, ");"].}
embedsC()
Вы можете emit
static int cvariable
, при этом коммуникация работает в обе стороны, так что вы также можете emit
инструкцию fprintf
, где переменная nimVar
, на самом деле, приходит из Nim (квадратные скобки позволяют использовать строки и именованные выражения одновременно в одном окружении). Код на C может использовать код на Nim и наоборот. Тем не менее, это не самый хороший способ взаимодействия языков, это просто демонстрация того, что мы хотим, чтобы вы могли сделать это в случае необходимости.
Гораздо лучший способ взаимодействия когда вы просто говорите Nim’у: «Эй, вот здесь функция fprintf
, она приходит из C, а это её типы, я бы хотел иметь возможность её вызывать». Тем не менее, прагма emit
хорошо показывает, что мы хотим, чтобы этот язык был практичным.
Настраиваемое управление памятью
И теперь совсем другая тема, так как мы совсем не поговорили об управлении памятью. В новой версии Nim базируется на деструкторах, которые вызываются в режиме gc:arc
или gc:orc
. Деструкторы и владение, я предполагаю, знакомые вам понятия из C++ и Rust.
Параметр sink
здесь означает, что функция получает во владение строку (и потом не делает ничего с x
):
func f(x: sink string) =
discard "do nothing"
f "abc"
Вопрос в следующем: «произвёл ли я утечку памяти? что произошло?». Вы можете попросить компилятор Nim: «Слушай, разверни эту функцию f
для меня; покажи где там стоят деструкторы, где происходят перемещения (moves), а где глубокое копирование» (скомпилируем с nim c --gc:orc --expandArc:f $file
).
Компилятор вам ответит: «Смотри, функция f
это, по сути, твоя инструкция discard
и я добавил вызов деструктора в самом конце»:
func f(x: sink string) =
discard "do nothing"
`=destroy`(x)
Классная штука здесь в том, что внутренний язык Nim это тоже Nim, и получается, что на Nim всё это отлично выражается.
Вот другой пример:
var g: string
proc f(x: sink string) =
g = x
f "abc"
Теперь я беру x
во владение и действительно что-то делаю, пока владею ей, а именно кладу x
в глобальную переменную g
. Снова, мы можем спросить компилятор что он сделает и компилятор ответит: «Это операция перемещения (move) , она называется =sink
». Так мы перемещаем x
в g
, и это перемещение позаботится о том, чтобы освободить то, что находится в g
(если там что-то было), а затем поместить туда значение x
:
var g: string
proc f(x: sink string) =
`=sink`(g, x)
f "abc"
Так вот, на самом деле здесь происходит, и, к сожалению, это не совсем очевидно, то, что компилятор сообщает: «ладно, x перемещается в g
, а когда будет сказано, что x перемещён, вызвать деструктор». Но вот это wasMoved
и =destroy
отменяют друг друга, так что компилятор провёл для нас здесь оптимизацию:
var g: string
proc f(x: sink string) =
`=sink`(g, x)
# optimized out:
wasMoved(x)
`=destroy`(x)
f "abc"
Собственный контейнер
Вы можете использовать эти перемещения, деструкторы и присвоения копированием (copy assignments) для создания собственных структур данных.
У меня есть несколько коротких примеров, но я не буду останавливаться на их деталях.
Деструктор:
type
myseq*[T] = object
len, cap: int
data: ptr UncheckedArray[T]
proc `=destroy`*[T](x: var myseq[T]) =
if x.data != nil:
for i in 0..<x.len: `=destroy`(x[i])
dealloc(x.data)
Оператор перемещения:
proc `=sink`*[T](a: var myseq[T]; b: myseq[T]) =
# move assignment, optional.
# Compiler is using `=destroy` and
# `copyMem` when not provided
`=destroy`(a)
a.len = b.len
a.cap = b.cap
a.data = b.data
Оператор присвоения:
proc `=copy`*[T](a: var myseq[T]; b: myseq[T]) =
# do nothing for self-assignments:
if a.data == b.data: return
`=destroy`(a)
a.len = b.len
a.cap = b.cap
if b.data != nil:
a.data = cast[typeof(a.data)](alloc(a.cap * sizeof(T)))
for i in 0..<a.len:
a.data[i] = b.data[i]
Предоставление доступа:
proc add*[T](x: var myseq[T]; y: sink T) =
if x.len >= x.cap: resize(x)
x.data[x.len] = y
inc x.len
proc `[]`*[T](x: myseq[T]; i: Natural): lent T =
assert i < x.len
x.data[i]
proc `[]=`*[T](x: var myseq[T]; i: Natural; y: sink T) =
assert i < x.len
x.data[i] = y
Смысл здесь в том, что деструкторы, операторы перемещения, и так далее могут быть написаны вами специально для ваших собственных контейнеров, после чего они отлично работают вместе со встроенными в Nim контейнерами, но одновременно дают вам очень тонкий контроль над аллокациями памяти и тем как именно они происходят.
И это очередное правило Nim:
Настраиваемое управление памятью
Zen of Nim
Давайте повторим все правила ещё раз в качестве итога:
Копирование плохого дизайна это плохой дизайн: мы хотим принимать хорошие решения, основываясь на первичных принципах, идущих непосредственно от проблемы.
Если компилятор не может рассуждать о коде, то и программист не может.
Тем не менее, не стой у программиста на пути. Компилятор как умный пёсик: вы можете обучить его новым трюкам и он действительно помогает вам, он может выполнять какие-то задания для вас, принести газету. Но в конечном счёте программист умнее компилятора.
Мы хотим перенести работу на время компиляции, потому что программы гораздо чаще запускаются, чем компилируются.
Мы хотим настраиваемое управление памятью.
Лаконичный код не мешает читабельности, он ей способствует.
Было ещё одно правило Дзена, призывающее задействовать метапрограммирование, чтобы оставить язык компактным. Однако сложно оставаться абсолютно искренним в этом месте, учитывая, сколько возможностей предлагает Nim. Есть некоторое напряжение между «мы хотим, чтобы язык был полным» и «мы хотим, чтобы язык был минималистичным». Чем старше становится Nim, тем больше он склоняется к полноте (все минималистичные языки вырастают, чтобы удовлетворять определённым потребностям).
Оптимизация это специализация. Я ещё не говорил про это правило, но если вам нужно больше скорости, вы действительно должны подумать о том, чтобы написать собственный код. Стандартная библиотека Nim не может предложить всё для всех, и для нас также гораздо сложнее предоставить вам лучшую библиотеку для всего, потому что лучшая библиотека должна быть общего назначения, она должна быть самой быстрой библиотекой, она должна иметь наименьшее количество накладных расходов для вашего времени компиляции, и этого действительно трудно достичь. Гораздо проще сказать: «хорошо, Nim предлагает это в качестве стандартной библиотеки, но здесь я сам написал 10 строчек, я могу забенчмаркнуть их и скорее всего мой собственный код будет быстрее, потому что он подогнан вручную для моего приложения». Так на самом деле: специализируйте свой код и он будет выполняться быстрее.
Наконец, должен быть только один язык программирования для всего.
И этот язык — Nim.
Спасибо за чтение!
Комментарии (24)
bm13kk
09.01.2022 20:36Лаконичный код не мешает читабельности, он ей способствует.
После просмотра ряда "известных" однострочников на си - я глубоко не согласен с этим утверждением. Да даже можно взять проще - правила разбора типизации на си.
inv2004
09.01.2022 20:43+1Возможно укороченная запись != обязательно лаконичная. Лаконичность - краткое и ясное выражение мыслей
bm13kk
09.01.2022 20:51я согласен с этим утверждением. Однако существование пункта 6 говорит о том, что не все считают что-то конкретное в ниме "ясным" выражением мысли. Если бы было всем ясно - проблемы бы не было.
WASD1
10.01.2022 16:41Лаконичность - краткое и ясное выражение мыслей
Тогда исходный тезис превращается в: "краткий и ясный код способствует читаемости", - трюизм.
napa3um
09.01.2022 20:43+2Малое количество букв в законченном предложении на том или ином языке превращают это предложение в шум при неправильном контексте, либо в информацию в правильном :). Тут нельзя эксклюзивно противопоставлять лаконичность многословности, оба варианта должны быть хорошо поддержаны в языке - нужно уметь лаконично описывать частые ситуации и однозначно и самоописательно описывать редкие :).
В качестве критики универсальности языка Nim (она точно такая же, как к языку Си++): авторы пытаются абстрагироваться от всех возможных (вероятных в будущем) библиотек каких угодно алгоритмов и подходов, как бы заранее предоставив выразительные средства для их сочинения. Это будто не язык, а фреймворк для создания языков. А потому прочесть "любую" строчку кода на Nim, зная только этот самый Nim, в общем случае не получится, придётся вникать в предметную область, в подходы, выбранные программистами конкретного проекта, вникать в выбранный набор ограничений.
Любая достаточно сложная система на любых языках (и их сочетании) приводит к появлению в проекте своего собственного языка (DSL), явно или неявно реализованного программистами, который торчит из конфигов проектируемой системы или структур данных персистентного хранения. Nim в этом смысле (как я понял авторов) выступает религиозным и неэффективным ограничением: пишите и конфиги на том же компилируемом языке, и хранимые структуры описывайте, правда, вам придётся сочинить это подмножество ограничений для конфигов / структур, а потом научить их остальных программистов проекта на Nim :). В общем, романтическая мечта об "едином языке", несовместимая с реальностью. Разницы между написанием проекта на [Ямл + Си] (например) и [Ним.Ямл + Ним.Си] нет никакой (кроме замыкания разработки на авторах Nim) :).
gecube
09.01.2022 21:27Nim в этом смысле (как я понял авторов) выступает религиозным и неэффективным ограничением: пишите и конфиги на том же компилируемом языке, и хранимые структуры описывайте, правда, вам придётся сочинить это подмножество ограничений для конфигов / структур, а потом научить их остальных программистов проекта на Nim :)
какой-то LISP получается...
napa3um
09.01.2022 21:38Да, будто бы целятся и в LISP (в роли "клея" - декларативного языка склейки логики системы), и в FORTH (в роли "бутстрапа" - рекурсивно самоопределяющего уровни системы языка). Эти языки тоже "беспредельно мощные", но таки поселились в своих довольно ограниченных практических нишах по итогу, идея гиперуниверсальности не взлетает :). Но это я оч со стороны и умозрительно обобщаю, сам на Nim не программирую :).
lea
09.01.2022 21:33+3Наконец, должен быть только один язык программирования для всего.
Всегда бесило вот такое навязывание единственного, расово верного инструмента.
Инструменты надо выбирать в соответствии с задачами (и некоторой оглядкой на легкость сопровождения). Как бы не был прекрасен любой конкретный язык - он бесполезен в отрыве от библиотек, которые требуются для решения задач. Мне нужен очень весомый повод чтобы тратить время на создание переходников и тем более - на написание аналога с нуля на нужном языке.
inv2004
09.01.2022 21:45+1Выступлю защитником Нима. Согласен, что это высказывание, вероятно, скорее звучит как агитка, очень часто, и не только автор этого языка, желают видеть своё детище единственным. Про звучание согласен, однако, если посмотреть на Nim, то он имеет FFI со многими языками гораздо более дружественную чем можно было бы ожидать после такого высказывания:
C - тут вообще без вопросов, так как Nim компилируется через него
JS - аналогично - Nim компилируется и в него
python - есть отличный nimpy
Собственно, но это конечно мой опыт, "тратить время на сздание переходников" тут приходится гораздо меньше чем во многих других языках. Но, с негативной оценкой этого высказывания согласен
iskateli
10.01.2022 01:22+2А что там с идентификаторами, всё такое же странное правило, когда например идентификатор
notin
равен идентификаторуnotIn
и это тоже самое что иNOT_IN
? Очень оттолкнуло в своё время.notin
=notIn
=NOT_IN
https://nim-lang.org/docs/manual.html#lexical-analysis-identifier-equality
inthewoods Автор
10.01.2022 03:00Более того!
let Variable: int = 123 echo V_A_R_I_A_B_L_E > 123
Это, конечно, шутка, так делать не надо. (хотя это валидный код)
А если серьёзно, то это вовсе не баг, а фича и осознанное решение. Если вам интересно, в unifficial faq есть объяснения позиции о регистрах/подчёркиваниях. Честное слово, первая реакция на этот факт у меня была такая же как и у вас, но, попользовавшись языком, я теперь склонен скорее согласиться с faq'ом.
Поскольку мало кто пойдёт по ссылке, то чтобы не быть голословным, суть там примерно такая:
Делать разным только регистр — это плохой стиль, лучше сделать разными имена, чтобы было понятно о чём речь (в вашем примере различия между
notin
=notIn
=NOT_IN
совершенно неочевидны).В принципе, нечувствительность обычно считается более дружественной пользователю, как например в файловых системах, конфигах и языках программирования (тут, наверное, должно быть «некоторых» или «даже», но следующий пункт объясняет что имеется ввиду).
Есть много примеров ЯП, где так же: Lisp, Basic, Pascal, Ada, Eiffel, Fortran. На Ada писали ПО для электростанций и самолётов и никто от этого не умер, значит человечеству от этого решения ничего не грозит.
Не надо путать нечувствительность к регистру и консистентность регистров в коде (что хороший стиль). Второго проще достичь с нечувствительностью к регистру в языке и правильно настроенной IDE
Это предотвращает ошибки. Когда код очень большой, уже сложно вспомнить как именно вы назвали сущность, и если язык к регистру нечувствителен, вы просто пишете его как привыкли и не боитесь, что промахнётесь.
inv2004
10.01.2022 14:11+1Моё понимание: данная фича сделана для сторонних модулей включая FFI в другие языки. При этом и сам Nim Style Guide и большинсво модулей придерживаются camelCase
inthewoods Автор
09.01.2022 21:46Тут, конечно, есть заигрывание в «должен остаться только один», но вообще имеется ввиду как раз то, что если вы выбираете Nim, то вы можете использовать его в любой задаче. (При этом если есть важная библиотека, то никто вам не мешает ffi пробросить, это делается практически по щелчку)
lea
09.01.2022 22:05+1если вы выбираете Nim, то вы можете использовать его в любой задаче
Для меня первична задача, а не инструмент.
Допустим, надо обработать данные в формате csv. Если это однократная задача - в первую очередь я подумаю, нельзя ли сделать это в Excel. Если нет, и обработка достаточно проста - я, вероятно, воспользуюсь AWK. Если требуется сложная обработка и данные не слишком велики - я воспользуюсь R, одной строчкой прочитаю данные, и буду тратить время на решение задачи, а не на написание csv-парсера или переходника. И лишь в крайнем случае я буду пытаться обрабатывать csv файлы на C++. Как-то так.
inthewoods Автор
09.01.2022 22:11+1Всё так. Но к слову сказать, в Nim есть csv парсер в стандартной библиотеке, его не надо писать.
И работа с ним ни на йоту не сложнее, чем с аналогичным в питоне, например.
gecube
10.01.2022 01:26+3Парсер в стандартной библиотеке
И что? Это какое-то супер преимущество? У меня точно так же может быть задача разбора не csv, а xlsx поколоночно. Ну, ничего страшного, что не будет в стандартной библиотеке этого модуля, главное, чтобы он был легко доступен и достаточно качественный. В том же python кажется два или три модуля для такой Задачи легко найти
inthewoods Автор
10.01.2022 02:12+2Да нет же, просто вы написали, что будете писать парсер на C++ для csv в крайнем случае, и здесь, под этой статьёй, создаётся впечатление, что это имеет отношение к Nim'у.
Мне бы вот не пришло в голову брать Excel, для меня питон в этой задаче был бы образцом простоты, и Nim это сравнение легко выдерживает.
Для него (питона), безусловно, написано в 10 раз больше библиотек и они в 10 раз более проработаны, тут не поспоришь.
Но, опять же к слову, библиотека для разбора xlsx под Nim тоже есть и легко находится (правда пока WIP, но уже 0.4.5). Если верить описанию в репозитории, чтобы вывести содержимое xlsx файла надо 5 строчек, включая импорт и echo
gohrytt
10.01.2022 16:21+1Ним - очень интересный как проект. Но работать на нём с проектом на больше чем 100 строчек - боль. Вот этот весь короткий синтаксис и улучшайзеры в духе ну ты можешь написать f(a) но можешь и f a, но если ты результат передаешь в другую функцию ты должен b(f(a)) а b f a вызовет хз какое поведение бесит.
Js ненавидят за допущения и вседозволеность, но js динамический. Теперь вот сделали статический язык с допущениями и вседозволенностью. Мб народу и зайдёт, конечно, но вот лично я никак не готов на этом программировать.
inv2004
Удивляет какое продолжительное время Араки тащит это на себе, при этом так же удивляет объём и то, что многие вещи не бросаются на начальной стадии, как часто бывает когда ресурсов мало.
Помню как в issues к Nim спросили про аналог концепции владения из Rust и нету ли планов это перенять, Араки ответил что в настоящий момент планов таких нету, но подумать над этим стоит, и через пару релизов появился paper с предположением как это реализовать в Nim, и вот уже в Nim присутсвуют такие вещи как -gc:arc и -gc:orc, но пока эти gc не включены по-умолчанию.
Andreas Rumpf: Nim ARC/ORC (NimConf 2020) - YouTube
inthewoods Автор
А ещё ним, несмотря на всё что в нём есть, производит ощущение цельности! И, может быть, это как раз благодаря небольшой команде (он там, всё-таки, не один, если быть честными)
По управлению памятью, кстати, кажется, тут ещё ссылка на эту статью будет уместна:
Введение в ARC/ORC в Nim