[Перевод] Дзен Nim
Это расшифровка выступления Арака (Andreas Rumpf, создатель языка Nim — прим. пер.) на NimConf2021, случившегося 26 июня (вот запись на youtube и слайды на github). Пьетро Петерлонго адаптировал текст к публикации в блоге, а Арак дополнительно проверил его.
Zen of Nim
Копирование плохого дизайна — так себе дизайн.
Если компилятор не может рассуждать о коде, то и программист не может.
Не стой на пути у программиста.
Перенеси работу на этап компиляции: программы запускаются гораздо чаще, чем компилируются.
Настраиваемое управление памятью.
Лаконичный код не мешает читабельности, он ей способствует.
(Задействовать метапрограммирование, чтобы оставить язык компактным).
Оптимизация это специализация: если вам нужно больше скорости, пишите кастомный код.
Должен быть только один язык программирования для всего. Этот язык — Nim.
Примечание редактора.В оригинале выступления Zen of Nim был дан в конце (и без нумерации). Здесь мы размещаем его в самом начале, нумеруя, чтобы было проще ссылаться. Дальнейшее раскрытие этих правил происходит в контексте обсуждения языка вообще и не пытается воспроизвести указанный выше порядок. Статья следует за выступлением, от материала на слайдах до расшифровки с минимальной редактурой (это отразилось в неформальном тоне текста).
Содержание
Введение
В этом посте я собираюсь объяснить философию языка 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..
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()
Этот код производит следующее:
Zen of Nim
- A bunch of rules that make no sense.
Лифтинг
Вы можете воспользоваться метапрограммированием для лифтинга операций, которые снова и снова требуют их программировать.
Например, у нас есть квадратный корень для чисел с плавающей точкой, и теперь мы хотим операцию квадратного корня, которая будет работать для списка чисел с плавающей точкой. Я мог бы использовать вызов 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.. @[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"
Собственный контейнер
Вы можете использовать эти перемещения, деструкторы и пр