Это расшифровка выступления Арака (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.
Спасибо за чтение!