Хочется чего-то нового, быстрого, компилируемого, но при этом приятного на ощупь? Добро пожаловать под кат, где мы опробуем язык программирования Nim на реализации очередного клона игры 2048. Никаких браузеров, только хардкор, только командная строка!

В программе:


Who is the Nim?


Объективно:


Nim — статически типизированный, императивный, компилируемый. Может быть использован в качестве системного ЯП, так как позволяет прямой доступ к адресам памяти и отключение сборщика мусора. Остальное — тут.

Субъективно


Многие из появляющихся сейчас языков программирования стремятся предоставить одну (или несколько) killer-feature, пытаясь с помощью них решить широкий класс задач (go routines в Go, адское управление памятью в Rust и пр). Nim не предлагает какой-либо особенной возможности. Это простой язык программирования, по синтаксиску напоминающий Python. Зато Nim позволяет писать программы легко. Практически также легко, как на столь высокоуровневом Python. При этом получающиеся на выходе программы по производительности должны быть сравнимы с аналогами на C, так как компиляция происходит не до уровня какой-либо виртуальной машины, а именно до машинных кодов.

Как выглядит ООП в 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)


  1. alltiptop
    06.07.2015 08:43

    Анимации реализованы? Т.е. посимвольное «движение».


    1. Ostrovski Автор
      06.07.2015 08:45
      +1

      Нет, играть на самом деле не совсем удобно. Но свою цель она выполняет — мне нужна была быстрая реализация для Q-learning бота.


  1. xGromMx
    06.07.2015 11:19
    +1

    Получил странную ошибку при запуске

    field.nim(15, 14) Error: undeclared identifier: '..<'


    1. Ostrovski Автор
      06.07.2015 11:22
      +1

      Версия Nim должна быть 0.11.3.


  1. ateraefectus
    06.07.2015 12:30
    +2

    Спасибо за статью, сам недавно увлёкся «Нимом», развлечения ради сделал крестики-нолики на SDL, а вот с ООП как-то запутался. Теперь вроде разобрался :)
    Отправил вам пулл-реквест «Good buy» > «Good bye» :) Радикальный способ исправлять орфографические ошибки!


    1. Ostrovski Автор
      06.07.2015 12:31

      Shame on me) Спасибо, сказывается профессиональная деформация, видимо.


  1. andyN
    06.07.2015 13:12

    Почти все нравится в Ниме, пробовали даже тут писать какие-то системные утилитки на нем, но отсутствие поддержки gzip (и даже сторонних модулей) неприятно удивило. Писать модуль с нуля времени не было, поэтому вернулись обратно на Python.


    1. Ostrovski Автор
      06.07.2015 13:32
      +1

      Так в нем же поддержка С-библиотек буквально из коробки. Просто портируете h-шник и вперед.


      1. andyN
        06.07.2015 14:50
        -3

        Представьте, что человек последний раз программировал на C в школе. Возможно это и быстро, но нужно же разбираться во всем этом. Кстати, если это так быстро и просто — тем более вызывает удивление, что в стандартную библиотеку не была включена поддержка gzip.


        1. foxin
          06.07.2015 19:38

          В Си нету пакетов. От слова «совсем». Потому что невозможно создать такую библиотеку, которая удовлетворяла бы параметрам: работать на любом железе, работать быстро, работать эффективно. А от Си именно это и требуется. Поэтому все библиотеки разрабатываются отдельно. Тот же gzip будет одним под десктоп (максимальное быстродействие), другим под мобильную платформу (минимальное потребление процессора) и третьим — под микроконтроллер (минимальное потребление памяти).


  1. devpony
    06.07.2015 14:09
    +2

    Мне кажется, сломанный getch() и необходимость использовать костыль в виде питона для запуска простейшей консольной утилиты — самое полезное, что можно было вынести о Nim из статьи.


  1. Lorien_Elf
    06.07.2015 16:11
    +2

    > of cmdExit:
    echo «Good buy!»

    Хорошая покупка! :)


  1. btd
    06.07.2015 17:05

    А для nim есть какой либо пакетный менеджер?


    1. Ostrovski Автор
      06.07.2015 17:11
      +1

      Конечно, nimble.


  1. guai
    06.07.2015 18:29
    -2

    Очередной язык, где «оно вам не надо» (в данном случае ООП) только потому, что так разработчикам проще пилить компилятор?


    1. Ostrovski Автор
      06.07.2015 18:35

      Так есть же ООП. Или без ключевого слова class ощущения не те?


      1. guai
        06.07.2015 18:43

        Ну в статье я увидел только создание синглтонов и накидывание туда функций. Или там есть что-то более нормальное?


        1. Ostrovski Автор
          06.07.2015 18:48

          А в каком месте статьи Вам показалось, что написано про синглтоны? Каждый вызов newGame и initGame из раздела про создание объектов создает новую переменную типа Game или GameObj.


          1. guai
            06.07.2015 19:06
            -3

            ну значит я чего-то не понял… что тоже не в пользу языка говорит. Неинтуитивный дизайн


            1. Ostrovski Автор
              06.07.2015 19:10

              Не холивара ради, а чисто из интереса. А какой язык по-вашему обладает интуитивно понятным дизайном?


              1. guai
                06.07.2015 19:20

                Я не про интуитивную понятность человеку с улицы, я про понятность для программиста, знающего энной количество языков. У меня с десяток наберется. Ну и когда в языке конструкции, которые изначально не были капитально нелогичными, в другом, более новом языке вдруг приобретают другие смыслы — я такой дизайн не одобряю. Про интуитивные языки с ходу подумал про груви и цейлон. Они оба под JVM, один скриптовый другой типа скалы. И оба реюзают по максимуму привычные для ява-кодера концепции, где это не идет в разрез со смыслом.


    1. mjr27
      06.07.2015 22:00

      Тута скорее ООП в стиле перла и Лиспа в духе: «хотите привычный ООП с классами и прочим cpp-каргокультом? На здоровье, язык это не запрещает»


      1. guai
        06.07.2015 22:10

        с каких пор классическое ооп стало карго-культом?


        1. mjr27
          06.07.2015 22:20
          +1

          Тут зависит от того, что Вы понимаете под классическим ООП.
          Если наследование, инкапсуляцию, полиморфизм, то оно как учение Маркса, всесильно, потому что оно верно.
          Если же «классы, конструкторы, деструкторы, private/protected/public + прочая калька с cpp/java/любойДругойДиалект» — то на мой взгляд с тех, когда это скопипастили из C++ в java


          1. guai
            06.07.2015 22:42
            -3

            какой ужос, второй по популярности язык программирования бездумно скопировал модель ооп у первого, просто чтоб всё было на вид как у белых людей


          1. Ostrovski Автор
            06.07.2015 23:02
            +2

            OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. © Alan Kay

            Наследование — это лишь один из способов реализации [позднего связывания]. Полиморфизм вообще не понятно к чему из перечисленного Аланом относится.


            1. mjr27
              07.07.2015 10:10

              Ну, справедливости ради, это не противоречит моему утверждению; эта «святая троица» просто одна из (достаточно удачная, правда) рабочих реализаций идеи. Полиморфизм я бы лично к messaging отнес, кстати.
              Но в целом согласен, это я сильно сузил.


  1. 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)
    }
    


    1. Ostrovski Автор
      06.07.2015 23:18

      Хороший вопрос! Тоже люблю такие штуки в коде. К сожалению мои 300 строк на Nim не заставили меня столкнуться с такой необходимостью. Может быть зададите его на StackOverflow?


      1. rekby
        07.07.2015 09:45

        На stackoverflow я думаю надо задавать практические вопросы — т.е. если уже буду использовать ним и что-то конкретное будет неполучаться/не пониматься — да. А пока нет конкретной задачи мне кажется это не на стек.


    1. 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)
      

      Таким образом, ошибка будет поймана на этапе компиляции, что, собсно, и требовалось.


      1. 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())
        	}
        }
        


        1. Ostrovski Автор
          07.07.2015 09:56

          Да, минус такого решения — это необходимость просматривать весь код функции, чтобы понять реальный интерфейс T. Напоминает duck typing с проверкой на этапе компиляции. В принципе, у шаблонных функций/методов в C++ та же проблема. Где-то (в boost?) видел адское решение, которое позволяет задать требования к T на уровне кода при объявлении шаблона. Выглядит жутко конечно.

          С массивом аналогичное решение.


          1. rekby
            07.07.2015 10:11

            т.е. если я положу в массив указатели на какой-то базовый объект, вроде Object или void, то потом в цикле по массиву шаблон будет на этапе выполнения кода уже подбирать какие методы вызывать?

            Можете небольшой пример в несколько строк накидать — для работы с массивом как у меня во втором примере, я потом уже с ним у себя поэкспериментирую — так ли он себя ведет как нужно.


            1. Ostrovski Автор
              07.07.2015 10:48

              На данный момент у меня даже не получилось создать массив разнородных объектов =) Пока пытался это сделать, нашел вот такую интересную возможность (правда она не сильно нужна для этой задачи):

              proc foo[T: MyType1|MyType2](x: T) = echo "do something"
              

              P.S. Шаблоны в Nim это немного другая вещь. Мы сейчас говорим про Generic'и. И они работают на этапе компиляции, а не выполнения.


              1. rekby
                11.07.2015 18:55

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


              1. 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
                

                Сгенерированный сишный код для этого примера особенно хорош.


                1. Ostrovski Автор
                  18.07.2015 11:29

                  Дерево объектов — это как-то печально. Получается «нецелевое» использование наследования. А когда множественное наследование не существует, дерево объектов вообще будет практически неприменимо.


                  1. 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.
                    Правда, примеров не густо.


      1. mjr27
        07.07.2015 10:18

        template foobarCaller(self) = self.foobar()
        мне такое в голову пришло (эффект тот же), но Ваш вариант явно лучше


      1. 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)