Pull to refresh

Зачем ЯОП? Зачем Racket?

Reading time15 min
Views15K
Original author: Matthew But­t­er­ick
Это продолжение статьи «Зачем Racket? Зачем Lisp?», которую я написал примерно через год после того, как открыл для себя Racket. Будучи новичком, я не мог понять дифирамбов, которые со всех сторон сыпались в адрес Lisp. Я не знал, что и думать. Как понимать, что Lisp в конце концов вызовет «глубокое просветление». Окей, как скажешь, бро.

У меня был простой вопрос: какая польза? В прошлой статье я попытался ответить на него и обобщил причины, почему кто-то захочет изучить Lisp или, в частности, Racket.

Я составил список из девяти особенностей языка, наиболее ценных для меня как новичка в Racket. Например, особенность № 5 — «создание новых языков программирования». Этот метод также называется языково-ориентированным программированием, или ЯОП.

С тех пор ЯОП стало моей любимой частью Racket, и я поделился своим восхищением в онлайн-книге «Прекрасный Racket», которая объясняет технику ЯОП и инструмент Racket.

Один из примеров в моей работе — Pollen. Я написал этот язык программирования для удобного типографского оформления своих онлайн-книг. В Pollen предыдущий параграф программируется следующим образом:

#lang pollen
Я составил список из ◊link["https://beautifulracket.com/appendix/why-racket-why-lisp.html#so-really-whats-in-it-for-me-now"]{девяти особенностей языка}, наиболее ценных для меня как новичка в Racket. Например, особенность № 5 — «создание новых языков программирования». Этот метод также называется ◊em{языково-ориентированным программированием}, или ◊em{ЯОП}.

Другой пример — brag, генератор парсеров (в стиле lex/yacc), который в качестве исходного кода принимает грамматику BNF. Простой пример для языка bf:

#lang brag

bf-program : (bf-op | bf-loop)*
bf-op      : ">" | "<" | "+" | "-" | "." | ","
bf-loop    : "[" (bf-op | bf-loop)* "]"

Оба языка реализованы в Racket и могут запускаться с обычным интерпретатором Racket или внутри Racket IDE (которая называется DrRacket).

Главные вопросы


И всё же… Несмотря на то, что книга заставила тысячи людей начать изучение Racket, мне иногда кажется, что я ступаю на ту же зыбучую почву, что и фанаты Lisp, которых я когда-то критиковал.

Если ЯОП настолько классный, то зачем тратить несколько дней на чтение книги. Правильно? Я смогу объяснить всё кратко, без лишних слов. Нужно ответить на два простых вопроса:

  1. Какие проблемы лучше всего подходят для языкового программирования?
  2. Почему Racket лучше всего подходит для создания языков?

Второй вопрос простой. Первый — нет. Мне задавали его много раз. Я часто цитировал знаменитую фразу судьи Поттера Стюарта: вы это поймёте, когда увидите. Ответ достаточно хорош для тех, кому действительно интересно. Но не для тех, кто стоит стороны и хотел бы услышать содержательные аргументы.

Итак, я попытаюсь. Имейте в виду, что я не профессор информатики и не могу рассуждать о теории языков программирования. Скорее, я использую Racket и предметно-ориентированные языки (DSL) для практических целей: от них зависит моя ежедневная работа. Поэтому сосредоточусь на практических аспектах.

Краткий ответ


  1. ЯОП на самом деле представляет собой метод проектирования интерфейса. Он идеален для задач, которые требуют минимальной нотации с сохранением максимальной точности. Минимальная нотация значит единственная разрешённая нотация. Ничего лишнего. Максимальная точность, то есть значение этой нотации, — именно то, что вы говорите. Никакой двусмысленности или шаблонов. ЯОП добирается до сути, как ничто другое.

    (Нетерпеливые могут перейти к конкретным категориям задач, которые выиграют от ЯОП).
  2. Racket идеально подходит для ЯОП из-за своей системы макросов. Они работают в стиле компилятора, упрощая преобразование кода. Система макросов Racket лучше любой другой.

На этом моменте половина читателей статьи захочет опубликовать анонимные комментарии с критикой моих тезисов. Но пожалуйста, имейте в виду: я в любом случае в выигрыше. ЯОП и Racket невероятно увеличили мою продуктивность в программировании. Я рад поделиться этим знанием, чтобы вы тоже могли воспользоваться этими преимуществами. Но я также буду рад, если эти инструменты останутся моим секретным оружием. В этом случае я останусь в 0,01% самых производительных программистов, получая более впечатляющий и прибыльный результат, чем остальные 99,9%

Так что выбор за вами.

Длинный ответ


Если подумать над самыми важными вопросами, то они сводятся к одному метавопросу: почему трудно объяснить преимущества ЯОП?

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

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

Языки общего назначения и предметно-ориентированные языки


Во-первых, немного терминологии. Языково-ориентированное программирование (оно же ЯОП) — идея решать проблемы программирования путём создания нового языка, а затем написания программы на нём. Часто такие «маленькие языки» называются предметно-ориентированными языками (DSL).

Как следует из названия, предметно-ориентированными язык адаптирован к задачам определённой области. Например, PostScript, SQL, make, регулярные выражения, .htaccess и HTML считаются предметно-ориентированными языками. Они не пытаются делать всё. Скорее, они сосредоточены на том, чтобы делать одну вещь хорошо.

На другом конце спектра — языки общего назначения. Здесь мы видим C, Pascal, Perl, Java, Python, Ruby, Racket и т. д. Почему они не считаются предметно-ориентированными? Потому что позиционируют себя для широкого спектра вычислительных задач.

На практике языки общего назначения часто специализируются в какой-то области. Например, C лучше других подходит для системного программирования. Perl — для скриптов в системном администрировании. Python выделяется как язык для начинающих. Racket для языково-ориентированного программирования. В каждом случае это то, для чего изначально разработан язык.

Между DSL и языками общего назначения тонкая грань. Например, Ruby создавался как язык общего назначения, но стал популярным в основном для веб-приложений через ассоциацию с Ruby on Rails. С другой стороны, JavaScript изначально был предметно-ориентированным языком для скриптов веб-браузера. Но он мутировал, словно вирус, и с тех пор вырос далеко за пределы изначальной задачи.

Что такое язык?


Если весь этот широкий спектр называется языками, то каковы определяющие особенности языка?

Я знаю, о чём вы думаете: «Вот тут ты ошибаешься. HTML — это не язык. Это просто разметка. Он не может описать алгоритм». Или: «Регулярные выражения — это не язык. Они не работают сами по себе. Это просто синтаксис для другого языка».

Когда-то я тоже так думал. Но чем пристальнее я вглядывался, тем более туманными казались эти различия. Таким образом, мое первое основное утверждение (из трёх): язык программирования по своей сути является средством обмена — системой обозначений, понятной людям и компьютерам.

«Система обозначений» (нотация) означает, что у языка есть синтаксис. «Понятный» означает, что своим синтаксисом язык передаёт значение (или семантику, если использовать более причудливое слово). Это определение охватывает все языки программирования общего назначения. И все DSL. (Но не каждый поток данных, о чём подробнее расскажем позже).

(Кстати, хотя «программирование» и «язык» — слова, идиоматически используемые вместе, эти языки используются не только людьми для программирования компьютеров. Иногда они используются компьютерами для связи с нами (например, S-выражения), иногда для связи друг с другом (например, XML, JSON, HTML). Определённо, кажется неправильным исключать эти возможности. Но на практике, да — то, что мы обычно делаем с языком программирования, это, собственно, есть программирование).

Рассмотрим HTML-код: способ сообщить компьютеру — в частности, веб-браузеру — как нарисовать веб-страницу. Это система обозначений (угловые скобки, теги, атрибуты и т. д.), понятная человеку и компьютеру (атрибут charset указывает кодировку символов, тег p содержит абзац и т. д.).

Вот небольшая страница HTML:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>My web page</title>
  </head>
  <body>
    <p>Hello <strong>world</strong></p>
  </body>
<html>

Предположим, вы не согласны, что HTML является языком программирования. Хорошо. Выведем нашу страницу на Python. Это же настоящий язык программирования, верно?

print "<!DOCTYPE html>"
print "<html>"
print "<head>"
print "<meta charset=\"UTF-8\">"
print "<title>My web page</title>"
print "</head>"
print "<body>"
print "<p>Hello <strong>world</strong></p>"
print "</body>"
print "<html>"

Если Python является языком программирования, а HTML — нет, значит, этот образец Python является программой, а образец HTML — нет.

Очевидно, это вымученное различие. Здесь питонизация не добавляет ничего, кроме сложности и шаблонности. Самое пикантное, что единственный интересный семантический контент в программе Python — с точки зрения управления веб-браузером — это то, что встроено в HTML (возможно, теги HTML, такие как DOCTYPE, meta и strong можно рассматривать как функции, принимающие аргументы). Логика заставляет сделать вывод, что HTML, хотя он проще и менее гибок, всё равно является языком программирования.

Встроенные языки


Пример с HTML и Python мы придумали. Но встраивание DSL в другой язык встречается повсеместно. Языки, используемые таким образом, называются встроенными. Они представляют собой наиболее распространённую форму языкового программирования. Как программист, вы полагались на ЯОП в течение многих лет, даже если не знали его названия.

Например, регулярные выражения (другие примеры: printf для форматирования строк, CLDR для шаблонов даты/времени, SQL). Мы не можем думать о регулярном выражении как о независимом языке. Но каждый программист знает, что это такое:

^fo+(bar)*$

Более того, вероятно, вы можете ввести это регулярное выражение в свой любимый язык программирования, и оно просто будет работать. Такое согласованное поведение возможно только потому, что нотация регулярного выражения является встроенным языком, определённым извне (POSIX).

Как и в случае с HTML, мы могли бы написать эквивалентное выражение в нотации хост-языка. Например, Racket поддерживает регулярные выражения Scheme (SRE): это регулярные выражения с нотацией S-выражений. Вышеприведённый шаблон будет написан так:

(seq bos "f" (+ "o") (* (submatch "bar")) eos)

Но программисты на Racket редко используют выражения SRE. Они слишком длинные и их трудно запомнить.

Ещё один вездесущий пример встроенного DSL: математические выражения. Каждый программист знает, что это значит:

(1 + 2) * (3 / 4) - 5

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

Вы шутите, что HTML — это программирование?


Нет, так и есть. Я утверждаю, что HTML (и регулярные выражения, и математические выражения) квалифицируются как рудиментарные языки программирования. Это означает, что написание HTML (или регулярных выражений или математических выражений) квалифицируется как рудиментарное программирование.

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

Ловушка полноты по Тьюрингу


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

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

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

Ограничения языка


Под моё определение языка попадает что угодно? Нет.

  • Двоичные форматы данных не считаются языками. Например, файл jpeg. Хотя компьютер их может понять, человек нет. Или PDF: если его взломать, внутри есть некоторые части, которые читаются человеком. Но это связано с тем, как работает PDF. Не существует смысла в записи каких-то идей с помощью PDF-конструкций.
  • Текстовые файлы не являются языками. Предположим, у нас есть файл с «Илиадой» Гомера. Мы, люди, можем его прочитать и понять. Хотя компьютер может тривиально обработать файл, скажем, распечатав его содержимое, но текст внутри непонятен компьютеру.
  • Графические пользовательские интерфейсы не являются языками. Да, это системы обозначений (которые полагаются на текст и изображение). Но они понятны только людям. Компьютеры рисуют GUI, но не понимают их.

Языки как интерфейсы


Выше я описал язык программирования как «средство обмена» между людьми и компьютерами. Таким образом, языки вписываются в более широкую категорию, которую мы называем интерфейсами.

Это подводит ко второму базовому утверждению (из трёх): что языковое программирование в своей основе является методом проектирования интерфейса. Если вам нравится думать об интерфейсах, вам понравится ЯОП. Если нет, вы всё равно полюбите ЯОП за то, что оно делает возможным интерфейсы, недостижимые в ином случае.

Один из моих любимых примеров языка как интерфейса — brag, язык генератора парсеров, созданный с помощью Racket. Если вы когда-либо использовали цепочку инструментов lex/yacc, то знаете, что часто цель заключается в генерации парсера из грамматики BNF. Например, для языка bf она выглядит следующим образом:

bf-program : (bf-op | bf-loop)*
bf-op : ">" | "<" | "+" | "-" | "." | ","
bf-loop : "[" (bf-op | bf-loop)* "]"

Чтобы сделать парсер на языке общего назначения, нужно перевести эту грамматику в кучу собственного кода. Это утомительная работа. И бессмысленная — разве мы уже не записали грамматику? Зачем делать это снова?

Однако brag исполняет наше желание. Чтобы сделать парсер, мы просто добавляем в файл строчку #Lang brag, который волшебным образом преобразует грамматику BNF в исходный код brag:

#Lang brag
bf-программа : (Bf-op | Bf-loop)*
bf-op : ">" | "<" | "+" | "-" | "."| ","
Bf-loop : "["(Bf-op | Bf-loop)* "]"

Готово! При компиляции этот файл экспортирует функцию parse, которая реализует данную грамматику BNF.

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

Но программист на ЯОП постоянно делает такие интерфейсы.

Где язык — лучший интерфейс


Это подводит меня к моему третьему и последнему базовому тезису, что у языков уникальные преимущества среди интерфейсов. Разумеется, приведённые ниже категории не являются исчерпывающими или исключительными. Но я обнаружил, что ЯОП способно многое предложить в таких ситуациях:

1. Когда вы хотите создать интерфейс для менее квалифицированных программистов, или непрограммистов, или ленивых программистов (не недооценивайте размер последней категории).

Например, в Racket есть сложная библиотека веб-приложений. Но простой веб-сервер также можно быстро запустить с помощью языка web-server/insta:

#lang web-server/insta
(define (start request)
  (response/xexpr
   '(html (body "Hello LOP World"))))

Мэтью Флатт в статье «Создание языков в Racket» демонстрирует язык, который генерирует играбельные текстовые приключения. Как и brag, он больше похож на спецификацию, чем на программу, но всё работает:

#lang txtadv

===VERBS===

north, n
 "go north"

south, s
 "go south"

get _, grab _, take _
 "get"

===THINGS===

---cactus---
get
  "Ouch!"


===PLACES===

---desert---
"You're in a desert. There is nothing for miles around."
[cactus, key]

north
  meadow

south
  desert

2. Когда вы хотите упростить нотацию. Один из примеров — регулярные выражения. Другой пример — мой предметно-ориентированный язык Pollen для написания онлайн-книг. Pollen похож на Racket, только здесь вы начинаете работать в текстовом режиме и используете специальные символы для обозначения команд Racket, которые внедряются в контент (Pollen основан на языке документации Racket под названием Scribble, который берёт на себя основную часть нагрузки). Так, начало этого абзаца запрограммировано следующим образом:

Когда вы хотите упростить нотацию. Один из примеров — регулярные выражения. Другой пример — мой предметно-ориентированный язык ◊link["https://pollenpub.com/"]{Pollen} для написания онлайн-книг.

Pollen заботится о вставке всех необходимых тегов и преобразовании их в непогрешимый HTML. У меня остаются все преимущества ручной разметки (полный контроль над страницей), но никаких недостатков (скажем, я не могу случайно оставить незакрытый тег).

Другой пример упрощённой нотации — lindenmayer, язык генерации и рисования фракталов Lindenmayer system, как этот:



В обычном Racket программа Lindenmayer может выглядеть так:

#lang racket/base
(require lindenmayer/simple/compile)
(define (finish val) (newline))
(define (A value) (display 'A))
(define (B value) (display 'B))
(lindenmayer-system
 (void)
 finish
 3
 (A)
 (A -> A B)
 (B -> A))

Но можно использовать упрощённую нотацию, просто изменив обозначение #lang в верхней части файла:

#lang lindenmayer/simple
## axiom ##
A
## rules ##
A -> AB
B -> A
## variables ##
n=3

Язык предполагает, что вы уже знакомы с L-системой. Но упрощённая нотация позволяет легко записать ваши пожелания в программе, которая делает то, что вы хотите.

3. Когда вы хотите работать с существующей нотацией. Выше мы видели, как brag использует в качестве исходного кода грамматику BNF.

#lang brag
bf-program : (bf-op | bf-loop)*
bf-op      : ">" | "<" | "+" | "-" | "." | ","
bf-loop    : "[" (bf-op | bf-loop)* "]"

Другой пример. Люди, которые пробовали Pollen, говорили: «Да, это классно, но я предпочитаю Markdown». Никаких проблем: pollen/markdown — это диалект Pollen, который предлагает семантику Pollen, но принимает обычную нотацию Markdown:

Когда вы хотите упростить нотацию. Один из примеров — регулярные выражения. Другой пример — мой предметно-ориентированный язык [Pollen]("https://pollenpub.com/") для написания онлайн-книг.

Самое приятное? Этот диалект я написал всего за час, объединив парсер Markdown с существующим кодом.

4. Если хотите создать промежуточную цель для других языков. JSON, YAML, S-выражения и XML — всё это предметно-ориентированные языки, которые определяют форматы данных, предназначенные для машинной записи и чтения.

В «Прекрасном Racket» один учебный язык называется jsonic. Он позволяет вставлять выражения Racket в JSON, тем самым делая JSON программируемым. Исходный код выглядит так:

#lang jsonic
// a line comment
[
  @$ 'null $@,
  @$ (* 6 7) $@,
  @$ (= 2 (+ 1 1)) $@,
  @$ (list "array" "of" "strings") $@,
  @$ (hash 'key-1 'null
           'key-2 (even? 3)
           'key-3 (hash 'subkey 21)) $@
]

Компилируется в обычный JSON:

[
  null,
  42,
  true,
  ["array","of","strings"],
  {"key-1":null,"key-3":{"subkey":21},"key-2":false}
]

5. Когда основная часть программы — конфигурация. Например, Dotfiles можно охарактеризовать как DSL. Более сложный пример из Racket — это Riposte от Джессе Аламы, язык для тестирования HTTP API на основе JSON:

#lang riposte

$productId := 41966
$qty := 5
$campaignId := 1

$payload := {
  "product_id": $productId,
  "campaign_id": $campaignId,
  "qty": $qty
}

POST $payload cart/{uuid}/items responds with 200

$itemId := /items/0/cart_item_id

GET cart responds with 200

Как миниатюрный язык сценариев, Riposte намного умнее, чем средний dotfile. Он скрывает весь промежуточный код, необходимый для транзакций HTTP, и позволяет пользователю сосредоточиться на написании тестов. Это по-прежнему уборка в доме. Но вы хотя бы можете сосредоточиться на хозяйстве, о котором заботитесь.

Почему Racket?


Часто критики ЯОП спрашивают: «Зачем делать предметно-ориентированный язык? Ведь проще написать нативную библиотеку?»

Нет, не проще, если у вас есть правильный инструмент. Racket необычен: он разработан с нуля специально для ЯОП. Таким образом, реализация DSL в Racket быстрее, дешевле и проще, чем альтернативы. Например, в первом уроке своей книги я показал, как разработать новый язык за один час — даже если вы никогда не использовали Racket.

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

Таким образом, чтобы ответить на критику — нет, DSL не обязательно требует больше работы, чем родная библиотека. Более того, как мы уже видели, в качестве интерфейса язык может делать то, что не может родная библиотека.

Почему макрос?


Поскольку все DSL компилируются в Racket, программист должен написать некоторые синтаксические трансформаторы, которые преобразуют нотацию DSL в родную Racket. Эти преобразователи синтаксиса известны как макросы. На самом деле их можно охарактеризовать как расширения компилятора Racket.

Система макросов Racket обширна, элегантна и, несомненно, является жемчужиной его короны. Значительная часть моей книги посвящена удовольствия работы с макросами Racket. Могу назвать две выдающиеся функции:

  1. В Racket есть специализированная структура данных, которая называется синтаксическим объектом. Это средство обмена между макросами. В отличие от строки, которая может содержать только необработанный код, синтаксический объект Racket упаковывает код так, чтобы сохранить его иерархическую структуру, а также метаданные, такие как лексический контекст и местоположение в исходнике, и произвольные поля, называемые синтаксическими свойствами. Эти метаданные прикреплены к коду во время его различных преобразований (подробнее см. в главе «Синтаксические объекты»).
  2. Макросы Racket гигиеничны, то есть по умолчанию созданный макросом код сохраняет лексический контекст, из которого определён макрос. На практике это устраняет огромное количество лишних телодвижений, которые обычно требуются для DSL (подробнее см. в главе «Гигиена»).

Можно ли реализовать DSL, скажем, на Python? Конечно. Фактически, я написал свой первый DSL именно на Python — и до сих пор использую его в работе по дизайну типов. Ну, такое. Одного раза было достаточно. С тех пор я использую Racket.

Вывод: победа с ЯОП


На данный момент, у вас может быть одна из двух реакций:

  1. «ЯОП кажется интересным, но я не знаю, что с ним делать». Конечно, я не ожидал сразу же завербовать вас в свою армию. Скорее, цель состояла в том, чтобы направить ваше мышление в продуктивное русло. Теперь вы узнали о ранее незнакомом инструменте. Сегодня вы можете не увидеть в этом пользы. Но однажды вы столкнётесь с проблемой, которая выходит за рамки вашего любимого языка. В этот день и произойдёт ваше знакомство с ЯОП.
  2. «Окей, вы меня убедили, но я никак не cмогу внедрить ЯОП или Racket в наше рабочее окружение». История Джессе Аламы о том, как он представил свой продметно-ориентированный язык Riposte, — отличный пример победы над коллегами с помощью ЯОП (выделено ниже мной):

    [Можно попробовать] заранее получить разрешение сделать что-то на Racket. Думаю, это труднее, чем просто сделать что-то великое и показать его преимущества… В моём случае это означало поговорить с коллегами об их работе и спросить: «Как смоделировать предлагаемое изменение в API и убедиться, что всё работает?» Предполагаемый ответ: «Написать скрипт Riposte». Это момент, когда становится ясно, что [DSL], который я сделал, имеет реальные преимущества. Я даже не «проталкиваю» Racket. Я просто представляю DSL и показываю, как он помогает.

В конце статьи «Почему Racket? Почему Lisp?» я сказал, что язык Lisp «даёт вам возможность раскрыть свой потенциал программиста и мыслителя и тем самым повысить ваши ожидания относительно того, чего вы можете достичь».

ЯОП предлагает аналогичную возможность: повысить наши ожидания относительно того, что могут сделать для нас языки программирования. Языки — это не чёрные ящики. Это интерфейсы, которые мы можем спроектировать. При этом мы открываем новые возможности для того, что можно сделать с помощью программ.

Если можете найти лучшую технику программирования, используйте её. Теперь, когда у меня есть ЯОП и Racket, я никогда не вернусь обратно.

Дальнейшее чтение


Tags:
Hubs:
Total votes 15: ↑14 and ↓1+13
Comments6

Articles