Pull to refresh

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

Reading time 13 min
Views 29K


Привет, Хабр!


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


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


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


Как я нашел лучший в мире язык программирования. Часть 1
Как я нашел лучший в мире язык программирования. Часть 2
Как я нашел лучший в мире язык программирования. Часть Йо (2.72)


Это безумный кайф, когда ты пишешь код свободно, почти не думая, не ожидая 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 (по функционалу) — имхо

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


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


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

Пример: хотите вы написать многопоточное приложения, ядер-то много, а девать некуда.
Вот раздел официальной документации про потоки. Нет, понимаете, потоки — это отдельная большая часть языка, его фича, которую даже нужно включать флагом --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

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


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

Тот же код в 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 выключены — вы ведь не собираетесь в 2019 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 все поля жёстко заданы в одной секции.
В-третьих, что бесит больше всего — нельзя "переиспользовать" поля в разных типах:


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

Tags:
Hubs:
+42
Comments 51
Comments Comments 51

Articles