Хочется чего-то нового, быстрого, компилируемого, но при этом приятного на ощупь? Добро пожаловать под кат, где мы опробуем язык программирования 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/Ostrovski/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, и это благоприятно отразится на количестве часов, проводимых нами перед раличными прогресс-барами за нашими компьютерами.
Комментарии (41)
ateraefectus
06.07.2015 12:30+2Спасибо за статью, сам недавно увлёкся «Нимом», развлечения ради сделал крестики-нолики на SDL, а вот с ООП как-то запутался. Теперь вроде разобрался :)
Отправил вам пулл-реквест «Good buy» > «Good bye» :) Радикальный способ исправлять орфографические ошибки!Ostrovski Автор
06.07.2015 12:31Shame on me) Спасибо, сказывается профессиональная деформация, видимо.
andyN
06.07.2015 13:12Почти все нравится в Ниме, пробовали даже тут писать какие-то системные утилитки на нем, но отсутствие поддержки gzip (и даже сторонних модулей) неприятно удивило. Писать модуль с нуля времени не было, поэтому вернулись обратно на Python.
Ostrovski Автор
06.07.2015 13:32+1Так в нем же поддержка С-библиотек буквально из коробки. Просто портируете h-шник и вперед.
andyN
06.07.2015 14:50-3Представьте, что человек последний раз программировал на C в школе. Возможно это и быстро, но нужно же разбираться во всем этом. Кстати, если это так быстро и просто — тем более вызывает удивление, что в стандартную библиотеку не была включена поддержка gzip.
foxin
06.07.2015 19:38В Си нету пакетов. От слова «совсем». Потому что невозможно создать такую библиотеку, которая удовлетворяла бы параметрам: работать на любом железе, работать быстро, работать эффективно. А от Си именно это и требуется. Поэтому все библиотеки разрабатываются отдельно. Тот же gzip будет одним под десктоп (максимальное быстродействие), другим под мобильную платформу (минимальное потребление процессора) и третьим — под микроконтроллер (минимальное потребление памяти).
devpony
06.07.2015 14:09+2Мне кажется, сломанный getch() и необходимость использовать костыль в виде питона для запуска простейшей консольной утилиты — самое полезное, что можно было вынести о Nim из статьи.
guai
06.07.2015 18:29-2Очередной язык, где «оно вам не надо» (в данном случае ООП) только потому, что так разработчикам проще пилить компилятор?
Ostrovski Автор
06.07.2015 18:35Так есть же ООП. Или без ключевого слова class ощущения не те?
guai
06.07.2015 18:43Ну в статье я увидел только создание синглтонов и накидывание туда функций. Или там есть что-то более нормальное?
Ostrovski Автор
06.07.2015 18:48А в каком месте статьи Вам показалось, что написано про синглтоны? Каждый вызов newGame и initGame из раздела про создание объектов создает новую переменную типа Game или GameObj.
guai
06.07.2015 19:06-3ну значит я чего-то не понял… что тоже не в пользу языка говорит. Неинтуитивный дизайн
Ostrovski Автор
06.07.2015 19:10Не холивара ради, а чисто из интереса. А какой язык по-вашему обладает интуитивно понятным дизайном?
guai
06.07.2015 19:20Я не про интуитивную понятность человеку с улицы, я про понятность для программиста, знающего энной количество языков. У меня с десяток наберется. Ну и когда в языке конструкции, которые изначально не были капитально нелогичными, в другом, более новом языке вдруг приобретают другие смыслы — я такой дизайн не одобряю. Про интуитивные языки с ходу подумал про груви и цейлон. Они оба под JVM, один скриптовый другой типа скалы. И оба реюзают по максимуму привычные для ява-кодера концепции, где это не идет в разрез со смыслом.
mjr27
06.07.2015 22:00Тута скорее ООП в стиле перла и Лиспа в духе: «хотите привычный ООП с классами и прочим cpp-каргокультом? На здоровье, язык это не запрещает»
guai
06.07.2015 22:10с каких пор классическое ооп стало карго-культом?
mjr27
06.07.2015 22:20+1Тут зависит от того, что Вы понимаете под классическим ООП.
Если наследование, инкапсуляцию, полиморфизм, то оно как учение Маркса, всесильно, потому что оно верно.
Если же «классы, конструкторы, деструкторы, private/protected/public + прочая калька с cpp/java/любойДругойДиалект» — то на мой взгляд с тех, когда это скопипастили из C++ в javaguai
06.07.2015 22:42-3какой ужос, второй по популярности язык программирования бездумно скопировал модель ооп у первого, просто чтоб всё было на вид как у белых людей
Ostrovski Автор
06.07.2015 23:02+2OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. © Alan Kay
Наследование — это лишь один из способов реализации [позднего связывания]. Полиморфизм вообще не понятно к чему из перечисленного Аланом относится.mjr27
07.07.2015 10:10Ну, справедливости ради, это не противоречит моему утверждению; эта «святая троица» просто одна из (достаточно удачная, правда) рабочих реализаций идеи. Полиморфизм я бы лично к messaging отнес, кстати.
Но в целом согласен, это я сильно сузил.
rekby
06.07.2015 23:11+1Читал документацию Nim — не нашел там интерфейсов или абстрактных классов и т.п. только наследование — как там предлагают реализовывать что-то работающее с абстракцией — только через одну ветку наследования и принятия объекта базового класса как параметра?
Т.е. как сделать что-то вроде этого:
package main type MyType struct { A int B int } func (v MyType) Val() int { return v.A + v.B } type MyType2 struct { V int } func (v MyType2) Val() int { return v.V } type HaveVal interface { Val() int } func PrintVal(v HaveVal){ println(v.Val()) } func main(){ a := MyType{A:1, B:2} b := MyType2{V:5} PrintVal(a) PrintVal(b) }
Ostrovski Автор
06.07.2015 23:18Хороший вопрос! Тоже люблю такие штуки в коде. К сожалению мои 300 строк на Nim не заставили меня столкнуться с такой необходимостью. Может быть зададите его на StackOverflow?
rekby
07.07.2015 09:45На stackoverflow я думаю надо задавать практические вопросы — т.е. если уже буду использовать ним и что-то конкретное будет неполучаться/не пониматься — да. А пока нет конкретной задачи мне кажется это не на стек.
Ostrovski Автор
07.07.2015 07:38Пока спал, придумал вот что: можно решить эту проблему с помощью generic'ов:
type T1 = object foo: string T2 = object bar: string T3 = object baz: string method foobar(self: T1) = echo self.foo method foobar(self: T2) = echo self.bar proc foobarCaller[T](self: T) = self.foobar() var t1 = T1(foo: "T1") var t2 = T2(bar: "T2") var t3 = T3(baz: "T3") foobar(t1) # компилируется, результат "T1" foobar(t2) # компилируется, результат "T2" foobar(t3) # не компилируется (!) Error: type mismatch: got (T3) # but expected one of: # interface_test.foobar(self: T2) # interface_test.foobar(self: T1)
Таким образом, ошибка будет поймана на этапе компиляции, что, собсно, и требовалось.rekby
07.07.2015 09:44Ага, а какие-то требования к входному типу задавать можно (кроме как в комментариях) — чтобы еще и понятно было какие методы требуются внутри foobarCaller?
+ еще задача — складывать похожие объекты в массив и потом что-то с ними делать, тоже через интерфейс.
что-то вроде
... func main() { a := MyType{A: 1, B: 2} b := MyType2{V: 5} var arr = []HaveVal{a, b} for i := range arr { println(arr[i].Val()) } }
Ostrovski Автор
07.07.2015 09:56Да, минус такого решения — это необходимость просматривать весь код функции, чтобы понять реальный интерфейс T. Напоминает duck typing с проверкой на этапе компиляции. В принципе, у шаблонных функций/методов в C++ та же проблема. Где-то (в boost?) видел
адскоерешение, которое позволяет задать требования к T на уровне кода при объявлении шаблона. Выглядит жутко конечно.
С массивом аналогичное решение.rekby
07.07.2015 10:11т.е. если я положу в массив указатели на какой-то базовый объект, вроде Object или void, то потом в цикле по массиву шаблон будет на этапе выполнения кода уже подбирать какие методы вызывать?
Можете небольшой пример в несколько строк накидать — для работы с массивом как у меня во втором примере, я потом уже с ним у себя поэкспериментирую — так ли он себя ведет как нужно.Ostrovski Автор
07.07.2015 10:48На данный момент у меня даже не получилось создать массив разнородных объектов =) Пока пытался это сделать, нашел вот такую интересную возможность (правда она не сильно нужна для этой задачи):
proc foo[T: MyType1|MyType2](x: T) = echo "do something"
P.S. Шаблоны в Nim это немного другая вещь. Мы сейчас говорим про Generic'и. И они работают на этапе компиляции, а не выполнения.rekby
11.07.2015 18:55да, я понял что при компиляции. Поэтому и задал вопрос в ключе — что делать когда это потребуется решать при выполнении.
RPG
18.07.2015 00:50+1Можно посмотреть, как выкрутились в модуле json — банальные object variants, как в сишном GLib. Это для простых случаев. Если же заморачиваться, то решение — дерево объектов и методы. Я взял пример с сайта и чуть переделал:
type A = ref object of RootObj B = ref object of A x: int C = ref object of A s: string method `$`(v: A): string = quit "to override!" method `$`(v: B): string = $v.x method `$`(v: C): string = v.s var a: seq[A] = @[B(x:4), C(s:"Hello")] for v in a: echo v
Сгенерированный сишный код для этого примера особенно хорош.Ostrovski Автор
18.07.2015 11:29Дерево объектов — это как-то печально. Получается «нецелевое» использование наследования. А когда множественное наследование не существует, дерево объектов вообще будет практически неприменимо.
RPG
18.07.2015 19:33В документации так и написано, что это overkill feature. Кроме того, авторы языка не очень поощряют наследование:
In particular, preferring composition over inheritance is often the better design.
Правда, примеров не густо.
Composition (has-a relation) is often preferable to inheritance (is-a relation) for simple code reuse. Since objects are value types in Nim, composition is as efficient as inheritance.
mjr27
07.07.2015 10:18template foobarCaller(self) = self.foobar()
мне такое в голову пришло (эффект тот же), но Ваш вариант явно лучше
Ostrovski Автор
07.07.2015 10:26В коде опечатка, вызов foobar(t3) на самом деле — это вызов foobarCaller(t3).
Правильный кодtype T1 = object foo: string T2 = object bar: string T3 = object baz: string method foobar(self: T1) = echo self.foo method foobar(self: T2) = echo self.bar proc foobar(i: int) = echo "Int" & repr(i) proc foobarCaller[T](self: T) = self.foobar() var t1 = T1(foo: "T1") var t2 = T2(bar: "T2") var t3 = T3(baz: "T3") foobar(t1) # компилируется, результат "T1" foobar(t2) # компилируется, результат "T2" foobar(42) # компилируется, результат "Int42" foobarCaller(t3) # не компилируется (!) Error: type mismatch: got (T3) # but expected one of: # interface_test.foobar(self: T2) # interface_test.foobar(self: T1) # interface_test.foobar(i: int)
alltiptop
Анимации реализованы? Т.е. посимвольное «движение».
Ostrovski Автор
Нет, играть на самом деле не совсем удобно. Но свою цель она выполняет — мне нужна была быстрая реализация для Q-learning бота.