Мой опыт разработки на языке Nim

Пост также можно почитать на habr.com

Уже довольно давно я пишу свой игровой фреймворк — такой pet project для души. А так как для души нужно выбирать что-то, что нравится (а в данном случае — на чём нравится писать), то выбор мой пал на nim. В этой статье я хочу поговорить именно про nim, про его особенности, плюсы и минусы, а тема геймдева лишь задаёт контекст моего опыта — какие задачи я решал, какие трудности возникли.

Давным-давно, когда трава была зеленее, а небо чище, я встретил nim. Хотя нет, не так. Давным-давно я хотел заниматься разработкой игр, чтобы написать свою Самую Классную Игру — думаю, многие проходили через это. В те времена Unity и Unreal Engine только-только стали появляться на слуху и, вроде как, ещё не были бесплатными. Я не стал их использовать, не столько из-за жадности, сколько из-за желания написать всё самому, создать игровой мир полность с нуля, с самого первого нулевого байта. Да, долго, да, сложно, зато сам процесс приносит удовольствие — а что ещё для счастья надо?

Вооружившись Страуструпом и Qt, я хлебнул говна по самое небалуй, потому что, во-первых, не был одним из 10 человек в мире, знающих C++ хорошо, а, во-вторых, плюсы активно вставляли мне палки в колёса. Не вижу смысла повторять то, что за меня уже замечательно написал @platoff:

Это безумный кайф, когда ты пишешь код свободно, почти не думая, не ожидая core dumped перед каждым запуском, когда фичи добавляются прямо на глазах, вот теперь мы так можем, а теперь еще так, то скажите мне пожалуйста, какая мне разница что у меня нет темплейтов, если я даже не скучал по ним? Продуктивность — вот главная цель программиста, который делает вещи, и единственная задача инструмента который он использует.

Работая с C++, я постоянно думал, как мне написать то, что я хочу, а не что мне написать. Поэтому я перешёл на nim. С историей покончено, давайте же я поделюсь с вами опытом после нескольких лет работы на nim.

Общие сведения для тех, кто не в курсе

🔗
  • Компилятор открытый (MIT), разрабатывается энтузиастами. Создатель языка — Andreas Rumpf (Araq). Второй разработчик — Dominik Picheta (dom96), написавший книгу Nim in action. Также некоторое время назад компания Status стала спонсировать разработку языка, благодаря чему у nim'а появились ещё 2 фуллтайм-разработчика. Помимо них, разумеется, контрибутят и другие люди.
  • Недавно вышла версия 1.0, а это значит, что язык стабилен и "breaking changes" больше не ожидаются. Если раньше вы не хотели использовать unstable версию, потому что обновления могли сломать приложение, то теперь самое время попробовать nim в своих проектах.
  • Nim компилируется (или транспилируется) в C, C++ (которые далее компилируются в нативный код) или JS (с некоторыми ограничениями). Соотвественно, при помощи FFI вам доступны все существующие библиотеки для C и C++. Если нет нужного пакета на nim — поищите на си или плюсах.
  • Ближайшие языки — python (по синтаксису, на первый взгляд) и D (по функционалу) — имхо

Документация

🔗

Вообще-то с этим плохо. Проблемы:

  • Документация раскидана по разным источникам
  • Документация говно не в полной мере описывает все возможности языка
  • Документация порой слишком лаконична

Пример: хотите вы написать многопоточное приложения, ядер-то много, а девать некуда. Вот раздел официальной документации про потоки. Нет, понимаете, потоки — это отдельная большая часть языка, его фича, которую даже нужно включать флагом --threads:on при компиляции. Там shared или thread-local heap в зависимости от сборщика мусора, всякие shared memory и locks, thread safety, специальные shared-модули и хрен знает что ещё. Откуда я про это всё узнал? Правильно, из книги nim in action, форума, stack overflow, телевизора и от соседа, в общем откуда угодно, но не из официальной документации.

Или вот есть т.н. "do notation" — очень хорошо заходит при использовании шаблонов и тд, вообще везде где надо передать callback или просто блок кода. Где про это можно почитать? Ага, в мануале по экспериметальным фичам.

Согласитесь, собирать информацию по разным малоинформативным источникам — то ещё удовольствие. Если вы пишете на nim — вам придётся это делать.

На форуме и в github issues проскакивали предложения по улучшению документации, но дело так и не сдвинулось. Мне кажется, не хватает какой-то жёсткой руки, которая скажет "всё, комьюнити, берём лопаты и идём разгребать эту кучу г… ениальных разрозненных кусков текста."

К счастью, я отстрадал своё, поэтому представляю вам список nim-чемпиона

Документация

🔗
  • Tutorial 1, Tutorial 2 — с них начинать
  • Nim in action — толковая книжка, которая действительно хорошо объясняет многие аспекты языка, порой намного лучше оф. документации
  • Nim manual — мануал — описано практически всё, но нет
  • Nim experimental manual — а почему бы, собственно, не продолжить документацию на отдельной страничке?
  • The Index — тут собраны ссылки на всё, то есть вообще всё что можно найти в nim'е. Не нашли нужного в туториалзах и мануале — в индексе точно найдёте.

Уроки и туториалы

🔗
  • Nim basics — самые основы для новичков, сложные темы не раскрыты
  • Nim Days — небольшие проекты (live examples)
  • Rosetta Code — очень прикольно сравнивать решение одних и тех же задач на разных ЯП, в т.ч. nim
  • Exercism.io — здесь можно пройти "путь nim", выполняя задания
  • Nim by Example

Помощь

🔗
  • IRC — основное место обитания… ниммеров?, которое транслируется в Discord и Gitter. Никогда не пользовался IRC (да и сейчас не пользуюсь). Вообще это очень странный выбор. Есть ещё голубиная почта по ниму… ладно, шучу.
  • Nim forum. Возможности форума минимальны, но 1) тут можно найти ответ 2) тут можно задать вопрос, если п.1 не сработал 3) вероятность ответа больше 50% 4) на форуме сидят разработчики языка и активно отвечают. Кстати, форум написан на nim, и поэтому функциональность никакая
  • Nim telegram group — есть возможность задать вопрос и [не]получить ответ.
  • Есть ещё русская телеграм-группа, если вы устали от nim и не хотите о нём ничего слышать — вам туда :) (отчасти шутка)

Playground

🔗

Nim playground — тут можно запустить программу на nim прямо в браузере Nim docker cross-compiling — тут можно почитать, как запустить докер-образ и скомпилировать программу для разных платформ.

Пакеты

🔗
  • nimble.directory — тут собраны все опубликованные пакеты, доступные для установки через пакетный менеджер nimble. Curated list of packages — собранный энтузиастами список более-менее живых пакетов

Переход на nim с других языков

🔗

Что нравится

🔗

Нет смысла перечислять все возможности языка, но вот некоторые особенности:

Фрактал сложности

🔗

Nim предоставляет вам "фрактал сложности". Вы можете писать высокоуровневый код. Можете бодаться с сырыми указателями и радоваться каждой attempt to read from nil. Можете вставлять C-код. Можете писать вставки на ассемблере. Можете писать процедуры (static dispatch). Не хватает — есть "методы" (dynamic dispatch). Ещё? Есть дженерики, и есть дженерики, мимикрирующие под функции. Есть шаблоны (templates) — механизм замены, но не такой блевотный, как в C++ (там макросы — это всё ещё просто текстовая замена, или уже что-то поумнее?). Есть макросы, в конце концов — это как IDDQD, они включают режим бога и позволяют работать напрямую с AST и буквально заменять куски синтаксического дерева, или самостоятельно расширять язык как хотите.

То есть на "высоком" уровне вы можете писать хелловорлды и горя не знать, но никто вам не запрещает проводить махинации любой сложности.

Скорость разработки

🔗

Кривая обучения — не кривая. Это прямая. Установив nim, вы в первую же минуту запустите ваш первый hello world, а в первый же день вы напишете простую утилиту. Но и через пару месяцев вам будет что изучать. Например, я начинал с процедур, потом мне понадобились методы, через какое-то время мне очень пригодились дженерики, недавно я открыл для себя шаблоны в полной их красе, и при этом я ещё вообще не трогал макросы. Сравнивая с тем же rust или c++, "влиться" в nim гораздо проще.

Package management

🔗

Есть package manager под названием nimble, которые умеет устанавливать, удалять, создавать пакеты и подгружать зависимости. Когда создаёте свой пакет (= проект), в nimble можно прописать разные задачи (при помощи nimscript, который подмножество nim, исполняемый на VM), например, генерацию документации, запуск тестов, копирование ассетов итд. Nimble не только поставит нужные зависимости, но и вообще позволит сконфигурировать рабочее окружение для вашего проекта. То есть nimble — это, грубо говоря, CMake, который написали не извращенцы, а нормальные люди.

Читаемость и выразительность

🔗

Внешне nim очень похож на python с type annotations, хотя nim это не python вообще ни разу. Питонистам придётся забыть динамическую типизацию, наследование, декораторы и прочие радости, и вообще перестроить мышление. Не стоит пытаться перенести свой python-опыт в nim, ибо разница слишком большая. Поначалу очень хочется гетерогенных коллекций и миксинов с декораторами. но потом как-то привыкаешь жить в лишениях :)

Вот пример программы на nim:

type
  NumberGenerator = object of Service  # this service just generates some numbers

  NumberMessage = object of Message
    number: int

proc run(self: NumberGenerator) =
  if not waitAvailable("calculator"):
    echo "Calculator is unavailable, shutting down"
    return

  for number in 0..<10:
    echo &"Sending number {number}"
    (ref NumberMessage)(number: number).send("calculator")

Модульность

🔗

Всё разбито на модули, которые можно как угодно импортировать — импортировать только определённые символы, или все кроме определённых, или все, или ни одного и заставить пользователя указывать полный путь а-ля module.function(), и ещё импортировать под другим именем. Разумеется, всё это многообразие очень пригодится как ещё один агрумент в споре "какой язык программирования лучше", ну а в своём проекте вы будете тихонько везде писать import mymodule и о других вариантах не вспоминать.

Method call syntax

🔗

Вызов функции может быть записан по-разному:

double(2)
double 2
2.double()
2.double

С одной стороны, теперь каждый… пишет как ему нравится (а всем нравится по-разному, разумеется, причём по-разному даже в рамках одного проекта). Но зато все функции могут быть записаны как вызов метода, что очень сильно улучшает читаемость. В питоне может быть такое:

# араб-стайл: читаем справа налево, 
# а ещё можно добавить map и filter 
# и уехать в дурку
list(set(some_list))

Тот же код в nim можно было бы переписать более логично:

# читаем слева направо
some_list.set.list

ООП

🔗

ООП хоть и присутствует, но отличается от оного в плюсах и питоне: объекты и методы — разные сущности, и вполне могут существовать в разных модулях. Более того, вы можете написать свои методы для базовых типов вроде int:

proc double(number: int): int =
    number * 2

echo $2.double()  # prints "4"

С другой стороны, в nim присутствует инкапсуляция (первое правило модуля в nim: никому не рассказывать о идентификаторах без символа звёздочки). Вот пример стандартного модуля:

# sharedtables.nim
type SharedTable*[A, B] = object ## generic hash SharedTable
    data: KeyValuePairSeq[A, B]
    counter, dataLen: int
    lock: Lock

Тип SharedTable* помечен звёздочкой, значит, он "виден" в других модулях и его можно импортировать. Но вот data, counter и lock — приватные члены, и "снаружи" sharedtables.nim они недоступны. Это меня очень обрадовало, когда я решил написать некоторые дополнительные функции для типа SharedTable, навроде len или hasKey, и обнаружил, что у меня нет доступа ни к counter, ни к data, и единственный способ "расширить" SharedTable — написать свой, с бл

Вообще наследование используется намного реже, чем в том же питоне (по личному опыту), потому что есть method call syntax (см. выше) и Object Variants (см ниже). Путь nim — это скорее композиция, а не наследование. Так же и с полиморфизмом: в nim'е есть методы, которые могут быть переопределены в классах-наследниках, но это нужно явно указать при компиляции, используя флаг --multimethods:on. То есть по умолчанию методы не работают, что слегка подталкивает к работе без оных.

Compile-time execution

🔗

const — возможность вычислять что-то на этапе компиляции и "зашивать" это в результирующий бинарник. Это круто и удобно. Вообще в nim особое отношение ко "времени компиляции", даже есть ключевое слово when — это как if, но сравнение идёт на этапе компиляции. Можно написать что-то вроде

when defined(SDL_VIDEO_DRIVER_WINDOWS):
  import windows  ## oldwinapi lib
elif defined(SDL_VIDEO_DRIVER_X11):
  import x11/x, x11/xlib  ## x11 lib

Это очень удобно, хотя и есть ограничения на то, что можно вытворять на этапе компиляции (например, нельзя делать FFI вызовы).

Reference type

🔗

Ref type — аналог shared_ptr в C++, о котором позаботится сборщик мусора. Но можно и самому вызывать сборщик мусора в те моменты, когда это вам удобно. А можно попробовать разные варианты сборщиков мусора. А можно вообще отключить сборщик мусора и использовать обычные указатели.

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

Lambdas

🔗

Есть анонимные процедуры (aka лямбды в питоне), но в отличие от питона в анонимной процедуре можно использовать несколько statements:

someProc(callback=proc(a: int) -> int = var b = 5*a; result = a)

Exceptions

🔗

Есть исключения, их очень неудобно бросать: на python raise ValueError('bad value'), на nim raise newException(ValueError, "bad value"). Больше ничего необычного — try, except, finally, всё как у всех. Я, как сторонник исключений, а не кодов ошибок, ликую. Кстати, для функций можно указывать, какие исключения они могут бросить, и компилятор будет это проверять:

proc p(what: bool) {.raises: [IOError, OSError].} =
  if what: raise newException(IOError, "IO")
  else: raise newException(OSError, "OS")

Generics

🔗

Дженерики очень выразительные, например, можно ограничивать возможные типы

proc onlyIntOrString[T: int|string](x, y: T) = discard  # только int и string

А можно передавать тип вообще как параметр — выглядит как обычная функция, а на самом деле дженерик:

proc p(a: typedesc; b: a) = discard
# is roughly the same as:
proc p[T](a: typedesc[T]; b: T) = discard
# hence this is a valid call:
p(int, 4)
# as parameter 'a' requires a type, but 'b' requires a value.

Templates

🔗

Шаблоны (templates) — что-то вроде макросов в C++, только сделанных правильно :) — вы можете безопасно передавать в шаблоны целые блоки кода, и не думать о том, что подстановка что-то испортит в outer коде (но можно, опять же, сделать, чтобы испортила, если очень надо).

Вот пример шаблона app, который в зависимости от значения переменной вызывает один из блоков кода:

template app*(serverCode: untyped, clientCode: untyped) =
    # ...
    case mode
      of client:
        clientCode
      of server:
        serverCode
      else:
        discard

При помощи do я могу передавать целы блоки в шаблон, например:

app do:  # serverCode
  echo "I'm server"
  serverProc()
do:  # clientCode
  echo "I'm client"
  clientProc()

Interactive shell

🔗

Если нужно быстро что-то протестировать, то есть возможность вызвать "интерпретатор" или "nim shell" (как если вы запустите python без параметров). Для этого воспользуйтесь командой nim secret или скачайте пакет inim.

FFI

🔗

FFI — возможность взаимодействовать со сторонними библиотеками на C/C++. К сожалению, для использования внешней библиотеки вы должны написать враппер, объясняющий, откуда и что импортировать. Например:

{.link: "/usr/lib/libOgreMain.so".}
type ManualObjectSection* {.importcpp: "Ogre::ManualObject::ManualObjectSection", bycopy.} = object

Есть инструменты, делающие этот процесс полуавтоматическим:

Что не нравится

🔗

Сложность

🔗

Слишком много всего. Язык задумывался как минималистичный, но сейчас это очень далеко от правды. Вот например за что мы получили code reordering?!

Избыточность

🔗

Много говнища: system.addInt — "Converts integer to its string representation and appends it to result". Мне кажется, это очень удобная функция, я её использую в каждом проекте. Вот ещё интересное: fileExists and existsFile (https://forum.nim-lang.org/t/3636)

Нет унификации

🔗

"There's only one way to do smth" — вообще нет:

  • Method call syntax — пиши вызов функции как хочешь
  • fmt vs &
  • camelCase и underscore_notation
  • this и tHiS (спойлер: это одно и то же)
  • function vs procedure vs template

Баги (нет, БАГИ!)

🔗

Баги есть, примерно 1400. Или просто зайдите на форум — там постоянно какие-то баги находят.

Стабильность

🔗

В дополнение к предыдущему пункту, v1 подразумевает стабильность, да? И тут на форум залетает создатель языка Araq и говорит: "чуваки, я тут запилил ещё один (шестой) сборщик мусора, он круче, быстрее, молодёжнее, даёт вам shared memory для потоков (ха-ха, а раньше для этого вы страдали и использовали костыли), качайте develop ветку и пробуйте". И все такие "Вау, как круто! А что это значит для простых смертных? Нам теперь опять весь код менять?" Вроде как нет, поэтому я обновляю nim, запускаю новый сборщик мусора --gc:arc и моя программа падает где-то на этапе компиляции c++ кода (т.е. не в nim, а в gcc):

/usr/lib/nim/system.nim:274:77: error: ‘union pthread_cond_t’ has no member named ‘abi’
  274 |   result = x

Великолепно! Теперь вместо того, чтобы писать новый код, я должен чинить старый. Не от этого ли я бежал, когда выбирал nim?

Приятно осознавать, что я не один.

Методы и многопоточность

🔗

По умолчанию флаги multimethods и threads выключены — вы ведь не собираетесь в 2020 году писать многопоточное приложение с переопределением методов?! А уж как здорово, если ваша библиотека создавалась без учёта потоков, а потом пользователь их включил… Ах да, для наследования есть замечательные прагмы {.inheritable.} и {.base.}, чтобы ваш код не был слишком лаконичен.

Object variants

🔗

Вы можете избежать наследования, используя т.н. object variants:

type
  CoordinateSystem = enum
    csCar, # Cartesian
    csCyl, # Cylindrical

  Coordinates = object
    case cs: CoordinateSystem: # cs is the coordinate discriminator
      of csCar:
        x: float
        y: float
        z: float
      of csCyl:
        r: float
        phi: float
        k: float

В зависимости от значения cs, вам будут доступны либо x, y, z поля, либо r, phi и k.

В чём минусы?

  • Во-первых, память резервируется для "самого большого варианта" — чтобы он гарантированно поместился в память, выделенную под объект.
  • Во-вторых, наследование всё равно более гибкое — всегда можете создать потомка и добавить ещё полей, а в object variant все поля жёстко заданы в одной секции.
  • В-третьих, что бесит больше всего — [нельзя "переиспользовать"](https://forum.nim-lang.org/t/5729 поля в разных типах:
type

  # The 3 notations refer to the same 3-D entity, and some coordinates are shared
  CoordinateSystem = enum
    csCar, # Cartesian    (x,y,z)
    csCyl, # Cylindrical  (r,φ,z)

  Coordinates = object
    case cs: CoordinateSystem: # cs is the coordinate discriminator
      of csCar:
        x: float
        y: float
        z: float  # z already defined here
      of csCyl:
        r: float
        phi: float
        z: float  # fails to compile due to redefinition of z

Do notation

🔗

Просто процитирую:

  • do with parentheses is an anonymous proc
  • do without parentheses is just a block of code

Одно выражение означает разные вещи ¯(ツ)

Когда что использовать

🔗

Итак, у нас есть функции, процедуры, дженерики, мультиметоды, шаблоны и макросы. Когда лучше использовать шаблон, а когда процедуру? Шаблон или дженерик? Функция или процедура? Так, а макросы? Я думаю, вы поняли.

Custom pragma

🔗

В питоне есть декораторы, которые можно применять хоть к классам, хоть к функциям. В nim для этого есть прагмы. И вот что:

  • Вы можете написать свою прагму, которая будет декорировать процедуру:
proc fib(n : int) : int {.cached.} =
  # do smth
  • Вы не можете написать свою прагму, которая будет декорировать тип (=класс).

Nimble

🔗

Что мертво — умереть не может. В nimble куча проектов, которые уже давно не обновлялись (а в nim это смерти подобно) — и их не убирают. Никто за этим не следит. Понятно, обратная совместимость, "нельзя просто взять и удалить пакет из репы", но всё же… Ладно, спасибо, что хоть не как npm.

Дырявые абстракции

🔗

Есть такой закон дырявых абстракций — вы используете какую-то абстракцию, но рано или поздно вы обнаружете в ней "дыру", которая приведёт вас на уровень ниже. Nim — это абстракция над C и C++, и рано или поздно вы туда "провалитесь". Спорим, вам там не понравится?

Error: execution of an external compiler program 'g++ -c  -w -w -fpermissive -pthread   -I/usr/lib/nim -I/home/user/c4/systems/network -o 
/home/user/.cache/nim/enet_d/@m..@s..@s..@s..@s..@s..@s.nimble@spkgs@smsgpack4nim-0.3.0@smsgpack4nim.nim.cpp:6987:136: note:   initializing argument 2 of ‘void unpack_type__k2dhaoojunqoSwgmQ9bNNug(tyObject_MsgStreamcolonObjectType___kto5qgghQl207nm2KQZEDA*, NU&)’
 6987 | N_LIB_PRIVATE N_NIMCALL(void, unpack_type__k2dhaoojunqoSwgmQ9bNNug)(tyObject_MsgStreamcolonObjectType___kto5qgghQl207nm2KQZEDA* s, NU& val) { nimfr_("unpack_type", "/home/user/.nimble/pkgs/msgpack4nim-0.3.0/msgpack4nim.nim");
      |                                                                                                                       
/usr/bin/ld: /home/user/.cache/nim/enet_d/stdlib_dollars.nim.cpp.o: in function `dollar___uR9bMx2FZlD8AoPom9cVY9ctA(tyObject_ConnectMessage__e5GUVMJGtJeVjEZUTYbwnA*)':
stdlib_dollars.nim.cpp:(.text+0x229): undefined reference to `resizeString(NimStringDesc*, long)'
/usr/bin/ld: stdlib_dollars.nim.cpp:(.text+0x267): undefined reference to `resizeString(NimStringDesc*, long)'
/usr/bin/ld: stdlib_dollars.nim.cpp:(.text+0x2a2): undefined reference to `resizeString(NimStringDesc*, long)'

Итак

🔗

Я тупой программист. Я не хочу знать, как работает GC, что там и как линкуется, куда кэшируется и как убирается мусор. Это как с машиной — я в принципе знаю, как она устроена, немного про сход-развал, немного про коробку передач, масло там надо заливать и прочее, но вообще я просто хочу сесть и ехать (причём быстро) на вечеринку. Машина — не цель, а средство достижения цели. Если она сломается — я не хочу лезть в капот, а просто отвезу её на сервис (в смысле, открою issue на гитхабе), и было бы здорово, если бы чинили её быстро.

Nim должен был стать такой машиной. Отчасти он и стал, но в то же время, когда я мчусь на этой машине по хайвею, у меня отваливается колесо, а заднее зеркало показывает вперёд. За мной бегут инженеры и на ходу что-то приделывают ("теперь с этим новым спойлером ваша машина ещё быстрее"), но от этого у меня отваливается багажник. И знаете что? Мне всё равно чертовски нравится эта машина, ведь это лучшая из всех машин, что я видел.



Новое