
Хочется чего-то нового, быстрого, компилируемого, но при этом приятного на ощупь? Добро пожаловать под кат, где мы опробуем язык программирования Nim на реализации очередного клона игры 2048. Никаких браузеров, только хардкор, только командная строка!
В программе:
- Who is the Nim?
- Как выглядит ООП в Nim
- Немного C под капотом
- Создание экземпляров
- Собственно игра 2048 (github)
- Субъективные выводы
Who is the Nim?
Объективно:
Nim — статически типизированный, императивный, компилируемый. Может быть использован в качестве системного ЯП, так как позволяет прямой доступ к адресам памяти и отключение сборщика мусора. Остальное — тут.
Субъективно
Многие из появляющихся сейчас языков программирования стремятся предоставить одну (или несколько) killer-feature, пытаясь с помощью них решить широкий класс задач (go routines в Go,
Как выглядит ООП в Nim
Код пишется в модулях (т.е. в файлах, Python-style). Модули можно импортировать в других модулях. Есть функции (proc), классов нет. Зато есть возможность создавать пользовательские типы и вызывать функции с помощью Uniform Function Call Syntax (UFCS) с учетом их перегрузки. Таким образом следующие 2 строки кода эквивалентны:
foo(bar, baz) bar.foo(baz)
А следующий код позволяет устроить ООП без классов в привычном понимании этого слова:
type Game = object foo: int bar: string Car = object baz: int # * означает, что эта функция будет доступна за пределами этого модуля при импорте # (��нкапсуляция) proc start*(self: Game) = echo "Starting game..." proc start*(self: Car) = echo "Starting car..." var game: Game var car: Car game.start() car.start()
Также есть методы (method). Фактически то же, что и proc, отличие лишь в моменте связывания. Вызов proc статически связан, т.е. информация о типе в runtime уже не имеет особого значения. Использование method же может пригодиться, когда нужно выбирать реализацию на основании точного типа объекта в существующей иерархии в момент исполнения. И да, Nim поддерживает создание новых типов на основе существующих, что-то вроде одиночного наследования, хотя предпочтение отдается композиции. Подробнее тут и тут.
Есть небольшая опасность — такая реализация ООП не подразумевает физическую группировку всех методов по работе с каким-либо типом в одном модуле. Таким образом, можно опрометчиво разбросать методы по работе с одним типом по всей программе, что, естественно, негативно скажется на поддержке кода.
Немного C под капотом
Хотя Nim компилируется до предела, он это делает через промежуточную компиляцию в C. И это круто, потому что при наличии определенного бэкграунда можно посмотреть, что же на самом деле происходит в коде на Nim. Давайте рассмотрим следующий пример.
Объекты в Nim могут быть значениями (т.е. располагаться на стеке) и ссылками (т.е. располагаться в куче). Ссылки бывают двух типов — ref и ptr. Ссылки первого типа отслеживаются сборщиком мусора и при нулевом количестве ref count, объекты удаляются из кучи. Ссылки второго типа являются небезопасными и нужны для поддержки всяких системных штук. В данном примере мы рассмотрим только ссылки типа ref.
Типичный для Nim способ создания новых типов выглядит примерно так:
type Foo = ref FooObj FooObj = object bar: int baz: string
Т.е. создается обычный тип FooObj и тип «ссылка на FooObj». А теперь давайте посмотрим, что происходит при компиляции следующего кода:
type Foo = ref FooObj FooObj = object bar: int baz: string var foo = FooObj(bar: 1, baz: "str_val1") var fooRef = Foo(bar: 2, baz: "str_val2")
Компилируем:
nim c -d:release test.nim cat ./nimcache/test.c
Результат в папке nimcache (test.c):
// ... typedef struct Fooobj89006 Fooobj89006; // ... struct Fooobj89006 { // выглядит как объявление типа FooObj. NI bar; NimStringDesc* baz; }; // ... STRING_LITERAL(TMP5, "str_val1", 8); STRING_LITERAL(TMP8, "str_val2", 8); Fooobj89006 foo_89012; //... N_CDECL(void, NimMainInner)(void) { testInit(); } N_CDECL(void, NimMain)(void) { void (*volatile inner)(); PreMain(); inner = NimMainInner; initStackBottomWith((void *)&inner); (*inner)(); } // Отсюда программа стартует на выполнение int main(int argc, char** args, char** env) { cmdLine = args; cmdCount = argc; gEnv = env; NimMain(); // это "главная" функция Nim, которая фактически делает вызов NimMainInner -> testInit return nim_program_result; } NIM_EXTERNC N_NOINLINE(void, testInit)(void) { Fooobj89006 LOC1; // это будущая foo и она на стеке Fooobj89006* LOC2; // это fooRef и она будет в куче NimStringDesc* LOC3; memset((void*)(&LOC1), 0, sizeof(LOC1)); memset((void*)(&LOC1), 0, sizeof(LOC1)); LOC1.bar = ((NI) 1); LOC1.baz = copyString(((NimStringDesc*) &TMP5)); foo_89012.bar = LOC1.bar; // это foo asgnRefNoCycle((void**) (&foo_89012.baz), LOC1.baz); LOC2 = 0; LOC2 = (Fooobj89006*) newObj((&NTI89004), sizeof(Fooobj89006)); // выделение памяти в куче под fooRef (*LOC2).bar = ((NI) 2); LOC3 = 0; LOC3 = (*LOC2).baz; (*LOC2).baz = copyStringRC1(((NimStringDesc*) &TMP8)); if (LOC3) nimGCunrefNoCycle(LOC3); asgnRefNoCycle((void**) (&fooref_89017), LOC2); }
Выводы можно сделать следующие. Во-первых, код при желании легко понять и разобраться, что же происходит под капотом. Во-вторых, для двух типов FooObj и Foo была создана всего одна соответствующая структура в C. При этом переменные foo и fooRef являются экземпляром и указателем на экземпляр структуры, соответственно. Как и говорится в документации, foo — стековая перменная, а fooRef находится в куче.
Создание экземпляров
Создавать экземпляры в Nim принято двумя способами. В случае, если создается переменная на стеке, ее создают с помощью функции initObjName. Если же создается переменная в куче — newObjName.
type Game* = ref GameObj GameObj = object score*: int // result - это неявная переменная, служащая для задания возвращаемого значения функции proc newGame*(): Game = result = Game(score: 0) // аналогично вызову new(result) result.doSomething() proc initGame*(): GameObj = GameObj(score: 0)
Создавать объекты напрямую с использованием их типов (в обход функций-конструкторов) не принято.
2048
Весь код игры уместился примерно в 300 строках кода. При этом без явной цели написать как можно короче. На мой взгляд, это говорит о достаточно высоком уровне языка.
С высоты птичьего полета игра выглядит так:

Код «main»:
import os, strutils, net import field, render, game, input const DefaultPort = 12321 let port = if paramCount() > 0: parseInt(paramStr(1)) else: DefaultPort var inputProcessor = initInputProcessor(port = Port(port)) var g = newGame() while true: render(g) var command = inputProcessor.read() case command: of cmdRestart: g.restart() of cmdLeft: g.left() of cmdRight: g.right() of cmdUp: g.up() of cmdDown: g.down() of cmdExit: echo "Good bye!" break
Отрисовка поля происходит в консоль при помощи текстовой графики и цветовых кодов. Из-за этого игра работает только под Linux и Mac OS. Ввод команд не удалось сделать через getch() из-за странного поведения консоли при использовании этой функции в Nim. Curses для Nim сейчас в процессе портирования и не указан в списке доступных пакетов (хотя пакет уже существует). Поэтому пришлось воспользоваться обработчиком ввода/вывода на основе блокирующего чтения из сокета и дополнительного python-клиента.
Запуск этого чуда выглядит следующим образом:
# в терминале 1 git clone https://github.com/iximiuz/nim-2048.git cd nim-2048 nim c -r nim2048 # в терминале 2 cd nim-2048 python client.py
Что хотелось бы отметить из процесса разработки. Код просто пишется и запускается! Такого опыта в компилируемых языках, не считая Java, я не встречал до этого. При этом написанный код можно считать «безопасным», если не используются указатели ptr. Синтаксис и модульная система очень сильно напоминают Python, поэтому привыкание занимает минимальное время. У меня уже была готовая реализация 2048 на Python, и я был приятно удивлен, когда оказалось, что код из нее можно буквально копировать и вставлять в код на Nim с минимальными исправлениями, и он начинает работать! Еще один приятный момент — Nim идет с батарейками в комплекте. Благодаря высокоуровневому модулю net код socket-сервера занимает меньше 10 строк.
Полный код игры можно посмотреть на github.
Вместо заключения
Nim красавчик! Писать код на нем приятно, а результат должен работать быстро. Компиляция Nim возможна не только в исполняемый файл, но и в JavaScript. Об этой интересной возможности можно почитать здесь, а поиграть в эмулятор NES, написанный на Nim и скомпилированный в JavaScript — здесь.
Хочется надеяться, что в будущем благодаря Nim написание быстрых и безопасных программ станет настолько же приятным процессом, как программирование на Python, и это благоприятно отразится на количестве часов, проводимых нами перед раличными прогресс-барами за нашими компьютерами.
