Это расшифровка выступления Арака (Andreas Rumpf, создатель языка Nim — прим. пер.) на NimConf2021, случившегося 26 июня (вот запись на youtube и слайды на github). Пьетро Петерлонго адаптировал текст к публикации в блоге, а Арак дополнительно проверил его.

Zen of Nim

  1. Копирование плохого дизайна — так себе дизайн.

  2. Если компилятор не может рассуждать о коде, то и программист не может.

  3. Не стой на пути у программиста.

  4. Перенеси работу на этап компиляции: программы запускаются гораздо чаще, чем компилируются.

  5. Настраиваемое управление памятью.

  6. Лаконичный код не мешает читабельности, он ей способствует.

  7. (Задействовать метапрограммирование, чтобы оставить язык компактным).

  8. Оптимизация это специализация: если вам нужно больше скорости, пишите кастомный код.

  9. Должен быть только один язык программирования для всего. Этот язык — Nim.

Примечание редактора.
В оригинале выступления Zen of Nim был дан в конце (и без нумерации). Здесь мы размещаем его в самом начале, нумеруя, чтобы было проще ссылаться. Дальнейшее раскрытие этих правил происходит в контексте обсуждения языка вообще и не пытается воспроизвести указанный выше порядок. Статья следует за выступлением, от материала на слайдах до расшифровки с минимальной редактурой (это отразилось в неформальном тоне текста).


Содержание


Введение

В этом посте я собираюсь объяснить философию языка Nim и почему Nim может быть полезен для широкого спектра областей применения, таких как:

  • научные вычисления

  • игры

  • компиляторы

  • разработка операционных систем

  • написание скриптов

  • и многих других

«Дзен» в заглавии означает, что мы придём к набору правил (показанных выше), которые направляют разработку языка и его эволюцию, но я буду говорить об этих правилах с помощью примеров.

Синтаксис

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

Nim использует синтаксис, основанный на отступах, вдохновлённый Haskell или Python. Это решение также сочетается с системой макросов.

Применение функции

Nim различает инструкции и выражения, и большинство выражений это применение функции (или «вызов процедуры»). Применение функции использует традиционную математическую запись со скобками: f(), f(a), f(a, b).

Но есть и сахар:

Сахар

Смысл

Пример

1

f a

f(a)

spawn log("some message")

2

f a, b

f(a, b)

echo "hello ", "world"

3

a.f()

f(a)

db.fetchRow()

4

a.f

f(a)

mystring.len

5

a.f(b)

f(a, b)

myarray.map(f)

6

a.f b

f(a, b)

db.fetchRow 1

7

f"\n"

f(r"\n")

re"\b[a-z*]\b"

8

f a: b

f(a, b)

lock x: echo "hi"

  • По правилам 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)


  1. inv2004
    09.01.2022 20:31

    Удивляет какое продолжительное время Араки тащит это на себе, при этом так же удивляет объём и то, что многие вещи не бросаются на начальной стадии, как часто бывает когда ресурсов мало.

    Помню как в issues к Nim спросили про аналог концепции владения из Rust и нету ли планов это перенять, Араки ответил что в настоящий момент планов таких нету, но подумать над этим стоит, и через пару релизов появился paper с предположением как это реализовать в Nim, и вот уже в Nim присутсвуют такие вещи как -gc:arc и -gc:orc, но пока эти gc не включены по-умолчанию.

    Andreas Rumpf: Nim ARC/ORC (NimConf 2020) - YouTube


    1. inthewoods Автор
      09.01.2022 21:25

      А ещё ним, несмотря на всё что в нём есть, производит ощущение цельности! И, может быть, это как раз благодаря небольшой команде (он там, всё-таки, не один, если быть честными)

      По управлению памятью, кстати, кажется, тут ещё ссылка на эту статью будет уместна:
      Введение в ARC/ORC в Nim


  1. bm13kk
    09.01.2022 20:36

    Лаконичный код не мешает читабельности, он ей способствует.

    После просмотра ряда "известных" однострочников на си - я глубоко не согласен с этим утверждением. Да даже можно взять проще - правила разбора типизации на си.


    1. inv2004
      09.01.2022 20:43
      +1

      Возможно укороченная запись != обязательно лаконичная. Лаконичность - краткое и ясное выражение мыслей


      1. bm13kk
        09.01.2022 20:51

        я согласен с этим утверждением. Однако существование пункта 6 говорит о том, что не все считают что-то конкретное в ниме "ясным" выражением мысли. Если бы было всем ясно - проблемы бы не было.


      1. WASD1
        10.01.2022 16:41

        Лаконичность - краткое и ясное выражение мыслей

        Тогда исходный тезис превращается в: "краткий и ясный код способствует читаемости", - трюизм.


    1. napa3um
      09.01.2022 20:43
      +2

      Малое количество букв в законченном предложении на том или ином языке превращают это предложение в шум при неправильном контексте, либо в информацию в правильном :). Тут нельзя эксклюзивно противопоставлять лаконичность многословности, оба варианта должны быть хорошо поддержаны в языке - нужно уметь лаконично описывать частые ситуации и однозначно и самоописательно описывать редкие :).

      В качестве критики универсальности языка Nim (она точно такая же, как к языку Си++): авторы пытаются абстрагироваться от всех возможных (вероятных в будущем) библиотек каких угодно алгоритмов и подходов, как бы заранее предоставив выразительные средства для их сочинения. Это будто не язык, а фреймворк для создания языков. А потому прочесть "любую" строчку кода на Nim, зная только этот самый Nim, в общем случае не получится, придётся вникать в предметную область, в подходы, выбранные программистами конкретного проекта, вникать в выбранный набор ограничений.

      Любая достаточно сложная система на любых языках (и их сочетании) приводит к появлению в проекте своего собственного языка (DSL), явно или неявно реализованного программистами, который торчит из конфигов проектируемой системы или структур данных персистентного хранения. Nim в этом смысле (как я понял авторов) выступает религиозным и неэффективным ограничением: пишите и конфиги на том же компилируемом языке, и хранимые структуры описывайте, правда, вам придётся сочинить это подмножество ограничений для конфигов / структур, а потом научить их остальных программистов проекта на Nim :). В общем, романтическая мечта об "едином языке", несовместимая с реальностью. Разницы между написанием проекта на [Ямл + Си] (например) и [Ним.Ямл + Ним.Си] нет никакой (кроме замыкания разработки на авторах Nim) :).


      1. gecube
        09.01.2022 21:27

        Nim в этом смысле (как я понял авторов) выступает религиозным и неэффективным ограничением: пишите и конфиги на том же компилируемом языке, и хранимые структуры описывайте, правда, вам придётся сочинить это подмножество ограничений для конфигов / структур, а потом научить их остальных программистов проекта на Nim :)

        какой-то LISP получается...


        1. napa3um
          09.01.2022 21:38

          Да, будто бы целятся и в LISP (в роли "клея" - декларативного языка склейки логики системы), и в FORTH (в роли "бутстрапа" - рекурсивно самоопределяющего уровни системы языка). Эти языки тоже "беспредельно мощные", но таки поселились в своих довольно ограниченных практических нишах по итогу, идея гиперуниверсальности не взлетает :). Но это я оч со стороны и умозрительно обобщаю, сам на Nim не программирую :).


  1. lea
    09.01.2022 21:33
    +3

    Наконец, должен быть только один язык программирования для всего.

    Всегда бесило вот такое навязывание единственного, расово верного инструмента.

    Инструменты надо выбирать в соответствии с задачами (и некоторой оглядкой на легкость сопровождения). Как бы не был прекрасен любой конкретный язык - он бесполезен в отрыве от библиотек, которые требуются для решения задач. Мне нужен очень весомый повод чтобы тратить время на создание переходников и тем более - на написание аналога с нуля на нужном языке.


    1. inv2004
      09.01.2022 21:45
      +1

      Выступлю защитником Нима. Согласен, что это высказывание, вероятно, скорее звучит как агитка, очень часто, и не только автор этого языка, желают видеть своё детище единственным. Про звучание согласен, однако, если посмотреть на Nim, то он имеет FFI со многими языками гораздо более дружественную чем можно было бы ожидать после такого высказывания:

      • C - тут вообще без вопросов, так как Nim компилируется через него

      • JS - аналогично - Nim компилируется и в него

      • python - есть отличный nimpy

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


      1. 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


        1. inthewoods Автор
          10.01.2022 03:00

          Более того!

          let Variable: int = 123
          echo V_A_R_I_A_B_L_E
          > 123

          Это, конечно, шутка, так делать не надо. (хотя это валидный код)

          А если серьёзно, то это вовсе не баг, а фича и осознанное решение. Если вам интересно, в unifficial faq есть объяснения позиции о регистрах/подчёркиваниях. Честное слово, первая реакция на этот факт у меня была такая же как и у вас, но, попользовавшись языком, я теперь склонен скорее согласиться с faq'ом.

          Поскольку мало кто пойдёт по ссылке, то чтобы не быть голословным, суть там примерно такая:

          1. Делать разным только регистр — это плохой стиль, лучше сделать разными имена, чтобы было понятно о чём речь (в вашем примере различия между notin = notIn = NOT_IN совершенно неочевидны).

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

          3. Есть много примеров ЯП, где так же: Lisp, Basic, Pascal, Ada, Eiffel, Fortran. На Ada писали ПО для электростанций и самолётов и никто от этого не умер, значит человечеству от этого решения ничего не грозит.

          4. Не надо путать нечувствительность к регистру и консистентность регистров в коде (что хороший стиль). Второго проще достичь с нечувствительностью к регистру в языке и правильно настроенной IDE

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


        1. inv2004
          10.01.2022 14:11
          +1

          Моё понимание: данная фича сделана для сторонних модулей включая FFI в другие языки. При этом и сам Nim Style Guide и большинсво модулей придерживаются camelCase


    1. inthewoods Автор
      09.01.2022 21:46

      Тут, конечно, есть заигрывание в «должен остаться только один», но вообще имеется ввиду как раз то, что если вы выбираете Nim, то вы можете использовать его в любой задаче. (При этом если есть важная библиотека, то никто вам не мешает ffi пробросить, это делается практически по щелчку)


      1. lea
        09.01.2022 22:05
        +1

        если вы выбираете Nim, то вы можете использовать его в любой задаче

        Для меня первична задача, а не инструмент.

        Допустим, надо обработать данные в формате csv. Если это однократная задача - в первую очередь я подумаю, нельзя ли сделать это в Excel. Если нет, и обработка достаточно проста - я, вероятно, воспользуюсь AWK. Если требуется сложная обработка и данные не слишком велики - я воспользуюсь R, одной строчкой прочитаю данные, и буду тратить время на решение задачи, а не на написание csv-парсера или переходника. И лишь в крайнем случае я буду пытаться обрабатывать csv файлы на C++. Как-то так.


        1. inthewoods Автор
          09.01.2022 22:11
          +1

          Всё так. Но к слову сказать, в Nim есть csv парсер в стандартной библиотеке, его не надо писать.

          И работа с ним ни на йоту не сложнее, чем с аналогичным в питоне, например.


          1. gecube
            10.01.2022 01:26
            +3

            Парсер в стандартной библиотеке

            И что? Это какое-то супер преимущество? У меня точно так же может быть задача разбора не csv, а xlsx поколоночно. Ну, ничего страшного, что не будет в стандартной библиотеке этого модуля, главное, чтобы он был легко доступен и достаточно качественный. В том же python кажется два или три модуля для такой Задачи легко найти


            1. inthewoods Автор
              10.01.2022 02:12
              +2

              Да нет же, просто вы написали, что будете писать парсер на C++ для csv в крайнем случае, и здесь, под этой статьёй, создаётся впечатление, что это имеет отношение к Nim'у.

              Мне бы вот не пришло в голову брать Excel, для меня питон в этой задаче был бы образцом простоты, и Nim это сравнение легко выдерживает.

              Для него (питона), безусловно, написано в 10 раз больше библиотек и они в 10 раз более проработаны, тут не поспоришь.

              Но, опять же к слову, библиотека для разбора xlsx под Nim тоже есть и легко находится (правда пока WIP, но уже 0.4.5). Если верить описанию в репозитории, чтобы вывести содержимое xlsx файла надо 5 строчек, включая импорт и echo


              1. gecube
                10.01.2022 02:18
                +1

                то не я писал, а коллега lea


                1. inthewoods Автор
                  10.01.2022 02:21

                  Приношу извенения, проглядел! Но сути ответа не меняет


  1. gohrytt
    10.01.2022 16:21
    +1

    Ним - очень интересный как проект. Но работать на нём с проектом на больше чем 100 строчек - боль. Вот этот весь короткий синтаксис и улучшайзеры в духе ну ты можешь написать f(a) но можешь и f a, но если ты результат передаешь в другую функцию ты должен b(f(a)) а b f a вызовет хз какое поведение бесит.

    Js ненавидят за допущения и вседозволеность, но js динамический. Теперь вот сделали статический язык с допущениями и вседозволенностью. Мб народу и зайдёт, конечно, но вот лично я никак не готов на этом программировать.


    1. inv2004
      10.01.2022 16:43
      +1

      некоторые только со скобками пишут: f(a,b) или a.f(b) , избегая разделение пробелами


      1. DarkEld3r
        11.01.2022 00:18
        +1

        Дык, (быть готовым) читать в итоге придётся всё равно все возможные варианты.