Pull to refresh

Дзен Nim

Reading time21 min
Views9.2K
Original author: Andreas Rumpf (Araq), Pietro Peterlongo

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

Спасибо за чтение!

Tags:
Hubs:
Total votes 15: ↑12 and ↓3+12
Comments29

Articles