Quantcast
Viewing all articles
Browse latest Browse all 8401

Nim: идеальный язык программирования

Image may be NSFW.
Clik here to view.
Рано или поздно разработчик достигает определенного профессионального уровня, осознавая недостатки своих инструментов, и стремится найти новые, которые, обладая преимуществами старых, лишены их ограничений. Свидетельство этого факта — нарастающий поток новых технологий и языков, появляющихся, как грибы после ливня.

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

Идеальный язык

Сегодня мы поговорим о малоизвестном звере среди набирающих популярность языков программирования: Nim.

На текущий момент Nim является идеальным для меня языком в сравнении с C++, Java, D, Rust, Ruby, JavaScript, PHP и многими другими. Nim органично объединяет удачные концепции из других языков, таких как Python, Lisp, C, Object Pascal, Ada, Modula-3 и лишь автору известно, каких еще. Прежде чем делать выводы, мне довелось написать на нем не одну тысячу строк. Предупреждаю, что некоторые детали будут не сразу понятны людям из «мейнстримовых» языков: чтобы осознать эти вещи, их нужно потрогать.

Итак, приступим.Характеристики моего идеального языка:

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

— Безопасность.Работа с данными в куче не обременяет управлением памятью. Освобождение системных ресурсов должно происходить автоматически. В той или иной степени это обеспечивают современные языки со сборщиком мусора и Rust.

— Простота синтаксиса.Не более пары часов на изучение. Новички, приходящие на проект, должны разобраться в коде так же быстро, как если бы он был написан на известном им языке.

— Выразительность.Язык должен быть расширяемым и дружелюбным к разного рода DSL. Ruby, Perl и некоторые другие языки имеют в этом определенные успехи.

— Скорость. Разумеется, мы хотим, чтобы наши программы использовали CPU лишь на полезные вычисления. Это требование отсеивает большинство динамически типизированных языков.

— Низкий уровень.Мы его не хотим, но иногда его не избежать. Ручное управление памятью, где это необходимо, адресная арифметика, задачи жесткого реального времени. И в таких случаях мы не хотим использовать другой, «более низкоуровневый» язык.

— Метапрограммирование.Мы не хотим использовать инструменты, которые генерируют код на нашем языке. Мы хотим, чтобы это происходило в пределах нашего языка, на стадии компиляции. Среди компилируемых языков на это способны Lisp, D и, в меньшей степени, Rust.

— Портируемость. Код на нашем языке должен запускаться везде, где запускается код на C. Любая ОС, любая архитектура, включая ARM, PIC и AVR, используемый в некоторых Arduino. Даже больше, было бы здорово запускать его в браузерах, поддерживающих Javascript!

— Совместимость с религиозно-несовместимыми экосистемами. За декады существования C, C++ и Java было написано множество отличных библиотек. Было бы здорово в случае необходимости использовать их в проекте, написанном на нашем идеальном языке.

Звучит утопично, но Nim соответствует всему вышесказанному, и я бы не поверил, если бы не убедился в этом на собственном опыте.


О том, как начать писать на Nim, о его синтаксисе и других банальностях вам непременно следует почитать здесь:
— Nim by example
— How I start
— Официальное руководство

В данной статье я хочу показать более изощренные возможности Nim, позаимствовав некоторые детали из презентации создателя языка Nim, Андреаса Румпфа (Andreas Rumpf), недавно прошедшей в рамках OSCON.

В том, что такие возможности представляют не только академический интерес, можно убедиться, рассмотрев исходники проекта nimx, который мы разрабатываем и используем в нашем игровом проекте. В частности, nimx поддерживает не только компиляцию в нативный код, но и в Javascript+WebGL.

Шаблоны

Начнем с выразительности и расширяемости синтаксиса:

template html(name, body) = # Объявляем шаблон, который объявляет
  proc name(): string = # процедуру с именем name, которая возвращает строку,
    result = "<html>" #  состоящую из открывающегося тега,
    body # того, что в нее добавит код body
    result.add("</html>") # и закрывающегося тега

template head(body) = #  Объявляем шаблон, предназначенный для использования внутри шаблона html
  result.add("<head>")
  body
  result.add("</head>")

...

template title(x) = # Этот шаблон принимает выражение, которое может быть преобразовано в строку,
  result.add("<title>$1</title>" % x) # которая впоследствии станет содержимым тега title

template li(x) = # Этот шаблон работает аналогично шаблону title
  result.add("<li>$1</li>" % x)

# Используем вышеописанные шаблоны:
html mainPage:
  head:
    title "The Nim programming language"
  body:
    ul:
      li "efficient"
      li "expressive"
      li "elegant"

echo mainPage()

Выполнение этого кода выведет результат:

<html><head><title>The Nim programming language</title></head><body><ul><li>efficient</li><li>expressive</li><li>elegant</li></ul></body></html>

Если любопытный читатель заглянет в промежуточный С-код, то он увидит, что весь HTML-код записан одной C-строкой.За это отвечает мощный механизм сворачивания констант (constant folding), реализованный в компиляторе Nim.

Макросы

Если же мощь шаблонов по какой-то причине вас не убедила, на помощь приходит тяжелая артиллерия — макросы. В Nim это процедуры, принимающие узлы AST (Abstract Syntax Tree — результат синтаксического анализа языка) в качестве аргументов и возвращающие модифицированный AST.

Следующий пример требует углубленных познаний в языке. Для примера попробуем добавить в Nim анализ покрытия кода (code coverage). Возьмем подопытную процедуру:

proc toTest(x, y: int) =
  try:
    case x
    of 8:
      if y > 9: echo "8.1"
      else: echo "8.2" # не покрыто
    of 9: echo "9" # не покрыто
    else: echo "else"
    echo "no exception"
  except IoError:
    echo "IoError" # не покрыто

toTest(8, 10)
toTest(10, 10)

В ней мы видим несколько ветвей в потоке управления. Нужно оценить, какие ветви покрыты тестом. Прежде, чем что-либо автоматизировать, следует сделать это вручную. Давайте напишем код, который должен сгенерировать макрос в результате работы с этой процедурой:

# Это код, который будет сгенерирован нашим макросом!

var
  track = [("line 11", false), ("line 15", false), ...] # Флаги о прохождении потока управления через контрольные строки

proc toTest(x, y: int) =
  try:
    case x
    of 8:
      if y > 9:
        track[0][1] = true # Контрольная строка
        echo "8.1"
      else:
        track[1][1] = true # Контрольная строка
        echo "8.2"
    of 9:
      track[2][1] = true # Контрольная строка
      echo "9"
    else:
      track[3][1] = true # Контрольная строка
      echo "foo"
    echo "no exception"
  except IoError:
    track[4][1] = true # Контрольная строка
    echo "IoError"

toTest(8, 10)
toTest(1, 2)

# Выводим результат анализа покрытия кода
proc listCoverage(s: openArray[(string, bool)]) =
  for x in s:
    if not x[1]: echo "NOT COVERED ", x[0]

listCoverage(track)

Теперь задача прояснилась. Нам нужно найти в подопытном коде все ветвления, в каждую из них добавить запись о прохождении, перед этим объявить все записи, и в конце вывести результат. Прежде чем модифицировать структуру AST, нам нужно ее хотя бы увидеть. Для этого наш макрос в начале своего существования будет лишь показывать структуру во время компиляции, оставляя ее неизменной:

import macros

macro cov(n: untyped): untyped = # Наша цель
  result = n # AST остается прежним
  echo treeRepr n # Вывести структуру AST

cov: # Применяем макрос
  proc toTest(x, y: int) =
    try:
      case x
      of 8:
        if y > 9: echo "8.1"
        else: echo "8.2"
      of 9: echo "9"
      else: echo "foo"
      echo "no exception"
    except IoError:
      echo "IoError"

  toTest(8, 10)
  toTest(10, 10)

В результате компиляции мы увидим следующее:

...
      TryStmt
        StmtList
          CaseStmt
            Ident !"x"
            OfBranch
              IntLit 8
              StmtList
                IfStmt
                  ElifBranch
                    Infix
                      Ident !">"
                      Ident !"y"
                      IntLit 9
                    StmtList [...]
                  Else
                    StmtList [...]
            OfBranch
              IntLit 9
              StmtList
                Command
                  Ident !"echo"
                  StrLit 9
            Else
              StmtList
                Command
                  Ident !"echo"
                  StrLit foo
          Command [...]
        ExceptBranch
          [...]

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

## Code coverage macro

import macros #  Для манипуляции узлами AST мы используем функции из стандартного модуля macros

proc transform(n, track, list: NimNode): NimNode {.compileTime.} =
  # Вспомогательная процедура transform, вызываемая макросом во время компиляции
  result = copyNimNode(n)
  for c in n.children:
    result.add c.transform(track, list)

  # Рассматриваем AST ветвления
  if n.kind in {nnkElifBranch, nnkOfBranch, nnkExceptBranch, nnkElse}:
    let lineinfo = result[^1].lineinfo

    template trackStmt(track, i) =
      track[i][1] = true
    result[^1] = newStmtList(getAst trackStmt(track, list.len), result[^1])

    template tup(lineinfo) =
      (lineinfo, false)
    list.add(getAst tup(lineinfo))

macro cov(body: untyped): untyped = # Собственно, макрос
  var list = newNimNode(nnkBracket)
  let track = genSym(nskVar, "track")
  result = transform(body, track, list)
  result = newStmtList(newVarStmt(track, list), result,
                   newCall(bindSym"listCoverage", track))
  echo result.toStrLit # Ради отладки, выведем измененный код

cov: # Применяем макрос
  proc toTest(x, y: int) =
    ...

  toTest(8, 10)
  toTest(10, 10)

Результат выполнения:

8.1
no exception
else
no exception
NOT COVERED coverage.nim(42,14)
NOT COVERED coverage.nim(43,12)
NOT COVERED coverage.nim(47,6)

Таким образом, менее чем в 30 строках кода мы реализовали полезный функционал, для которого в иных языках потребовались бы отдельные инструменты. С помощью макросов вы можете добавлять в язык различные функции, которых вам не хватает: интерполяция строк, pattern matching или что-то еще. Выверенный синтаксис Nim обеспечивает однозначность грамматики при использовании расширений. Увидев «загадочный» DSL-код, вы всегда знаете, с какими аргументами вызвать grep ;).

Управление памятью

Как уже было сказано выше, наличие развитых средств управления памятью является ключевой сильной стороной языка программирования. Nim позволяет использовать как ручной, так и автоматический способ управления памятью. Для этого в нем существуют такие модификаторы, как refи ptr.

Рассмотрим их назначение на примере объектов:

type Person = object
  name: string

Теперь у нас есть некий новый тип, и мы можем создавать объекты этого типа несколькими способами. Первый способ — создание объекта по значению:

var p = Person(name: “John”)

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

С другой стороны, мы можем выполнять выделение памяти под объект в управляемой сборщиком мусора куче, используя функцию new:

var pp = Person.new()

В таком случае переменная ppбудет ссылаться на объект, который находится в хранилище, управляемом сборщиком мусора, а реальный тип переменной будет ref Person (ref — управляемая ссылка). Такие объекты передаются в коде по ссылкам и умирают тогда, когда ссылки на данный объект в памяти отсутствуют.

Теперь перейдем к последнему, самому низкоуровневому способу работы с памятью, а именно — к работе с неуправляемым хранилищем памяти, которая осуществляется с помощью функций alloc, realloc и dealloc, по поведению напоминающие C-шные malloc, realloc и free.

var up = cast[ptr Person](alloc(sizeof(Person)))
# ...
dealloc(up)

Как можно определить из примера, в данном случае переменная up, как и в предыдущем случае, изменяет тип, то есть ее тип — не Person, а ptr Person, что обозначает небезопасный неуправляемый указатель, хранящий адрес объекта в памяти.

При этом в случае необходимости, для удобства использования таких типов с модификаторами, их можно явно обозначить с помощью ключевого слова type:

type
  PPerson = ref Person
  UPerson = ptr Person

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

Взаимодействие с другими экосистемами

Под капотом Nim использует C, C++, Objective-C или JavaScript как промежуточный код. Это значит, что использование библиотек, написанных на этих языках, довольно тривиально. Другие языки, как правило, предполагают механизмы расширения через C-интерфейс.И здесь Nim справляется прекрасно, позволяя писать бриджи к другим языкам, как библиотеки.

Недолго думая, я набросал небольшую библиотеку jnim, доступную на GitHub. jnim позволяет «импортировать» модули Java. Выглядит это так:

import jnim

jnimport:
  # Импортируем пару классов
  import java.lang.System
  import java.io.PrintStream

  # Импортируем статическое свойство
  proc `.out`(s: typedesc[System]): PrintStream

  # Импортируем метод
  proc println(s: PrintStream, str: string)

# Запускаем JVM. Это делать необязательно, если JVM уже запущен, к примеру, на Android.
let jvm = newJavaVM()

# Вызываем! :)
System.`.out`.println("This string is printed with System.out.println!")

Вся магия происходит внутри jnim. Для каждого определения jnimportсоздается одноименная сущность в Nim, и генерируется весь необходимый glue-код. Дальнейшим развитием jnim будет возможность не указывать поля и процедуры, а автоматически импортировать определения классов из Java-окружения на этапе компиляции.

Заключение

Nim — это мощный и практичный инструмент, достоинства которого трудно осветить в одной статье. Мы в ZEO Alliance недавно начали писать игровой проект на Nim, который, насколько я знаю, станет одной из первых коммерческих игр, написанных на этом языке.

Также мы популяризируем этот язык внутри Альянса, проводим ряд образовательных мероприятий для наших сотрудников и планируем пригласить в Украину автора Nim, Андреаса Румпфа.

Интересно, кто еще работает в этом направлении в Украине? Буду рад прочесть в комментариях ваши отзывы и мнения. Есть ли у вас опыт использования Nim? Сталкивались ли вы с задачами, для решения которых Nim был бы более эффективным инструментом?


Благодарю Ростислава Дзинько за секцию об управлении памятью в Nim и Андреаса Румпфа за вычитку и правку статьи.

Image may be NSFW.
Clik here to view.

Viewing all articles
Browse latest Browse all 8401

Trending Articles