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-код записан одной
Макросы
Если же мощь шаблонов по какой-то причине вас не убедила, на помощь приходит тяжелая артиллерия — макросы. В 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
, по поведению напоминающие
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 как промежуточный код. Это значит, что использование библиотек, написанных на этих языках, довольно тривиально. Другие языки, как правило, предполагают механизмы расширения через
Недолго думая, я набросал небольшую библиотеку 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 и Андреаса Румпфа за вычитку и правку статьи.
Clik here to view.