Когда C++ — это не просто язык, а конструктор языков
Представьте, что вы пишете сложную систему управления роботом-манипулятором. Каждый день вы вбиваете однотипные команды: «повернуть сервопривод на 30 градусов», «захватить деталь», «переместить в точку X:100 Y:200». Код на чистом C++ превращается в бесконечную простыню вызовов функций и циклов. А что если бы вы могли написать команду прямо как на человеческом языке: move arm to (100, 200) at speed 50? И чтобы это был настоящий, компилируемый C++ код?
Это не фантастика. Это — DSL (Domain-Specific Language), или предметно-ориентированный язык. На C++ вы можете создать свой мини-язык, который будет идеально описывать задачи вашей предметной области. В этой статье мы не просто поговорим о теории — мы напишем настоящий DSL на C++ с нуля, разберём два подхода (внутренний и внешний) и покажем, как это ускорит разработку в 5-10 раз.
Добро пожаловать в мир, где программист становится лингвистом, а компилятор — вашим переводчиком.
Что такое DSL и зачем он вам нужен?
Предметно-ориентированный язык (DSL) — это язык программирования, созданный для решения задач одной конкретной области. В отличие от общего языка (C++, Python, Java), DSL очень узко специализирован. Примеры, которые вы наверняка знаете: SQL (для баз данных), Regex (для поиска паттернов), HTML (для разметки веб-страниц).
Но зачем писать свой DSL на C++? Три главные причины:
- Читаемость кода. Ваш код становится похож на техническое задание. Его может читать и менять не только программист, но и инженер, математик, дизайнер.
- Скорость разработки. Вместо 20 строк шаблонного кода — одна строка на DSL.
- Безопасность. DSL не даст написать синтаксически неверную команду для вашей системы. Ошибки отлавливаются на этапе компиляции.
C++ идеален для создания DSL благодаря мощной системе шаблонов (template metaprogramming), операторам перегрузки и макросам. Вы можете сделать DSL, который будет выполняться так же быстро, как и обычный C++ код, потому что он будет встроен прямо в компилятор.
Два мира: Internal DSL vs External DSL
Прежде чем писать код, давайте выберем подход. Существует два типа DSL для C++:
Internal DSL (встроенный DSL)
Вы не пишете новый парсер. Вы используете синтаксис C++ — перегрузку операторов, цепочки вызовов, лямбды — чтобы создать «язык внутри языка». Это самый популярный подход в C++. Пример: библиотека Boost.Spirit или Eigen для линейной алгебры, где вы пишете v = (3.0 * m1 + m2) * m3; — это выглядит как математический DSL, но это чистый C++.
External DSL (внешний DSL)
Вы придумываете свой синтаксис с нуля, пишете парсер (или используете генератор парсеров типа ANTLR, Flex/Bison), а потом транслируете его в C++ код. Это сложнее, но даёт полную свободу. Например, вы можете написать скрипт на своём языке, а программа на C++ его выполнит.
В этой статье мы сделаем оба варианта. Начнём с простого — внутреннего DSL.
Практика 1: Создаём Internal DSL для конфигурации робота
Допустим, мы пишем библиотеку для управления роботом. Нам нужно задавать последовательность действий. Обычный код выглядел бы так:
Robot robot;
robot.connect("COM3");
robot.setSpeed(50);
robot.moveTo(100, 200);
robot.grip(true);
robot.moveTo(300, 400);
robot.grip(false);
Работает, но скучно. Давайте превратим это в DSL, используя цепочки методов и перегрузку операторов. Мы хотим писать так:
Robot robot;
robot << connect("COM3") << speed(50)
<< move(100, 200) << grip(on)
<< move(300, 400) << grip(off);
Как это реализовать? Используем шаблон «Builder» и перегрузку оператора <<.
#include <iostream>
#include <vector>
#include <string>
// Вспомогательные структуры для команд
struct Command {
virtual void execute() = 0;
virtual ~Command() = default;
};
struct ConnectCmd : Command {
std::string port;
void execute() override { std::cout << "Connecting to " << port << "\n"; }
};
struct MoveCmd : Command {
int x, y;
void execute() override { std::cout << "Moving to (" << x << ", " << y << ")\n"; }
};
struct SpeedCmd : Command {
int speed;
void execute() override { std::cout << "Set speed to " << speed << "\n"; }
};
struct GripCmd : Command {
bool state;
void execute() override {
std::cout << (state ? "Grip ON" : "Grip OFF") << "\n";
}
};
// Функции для создания команд (они возвращают unique_ptr)
auto connect(const std::string& p) { return std::make_unique<ConnectCmd>(p); }
auto move(int x, int y) { return std::make_unique<MoveCmd>(x, y); }
auto speed(int s) { return std::make_unique<SpeedCmd>(s); }
auto grip(bool s) { return std::make_unique<GripCmd>(s); }
// Класс Robot с перегруженным оператором <<
class Robot {
std::vector<std::unique_ptr<Command>> commands;
public:
Robot& operator<<(std::unique_ptr<Command> cmd) {
commands.push_back(std::move(cmd));
return *this;
}
void run() {
for (auto& cmd : commands) cmd->execute();
}
};
int main() {
Robot robot;
robot << connect("COM3") << speed(50)
<< move(100, 200) << grip(true)
<< move(300, 400) << grip(false);
robot.run();
return 0;
}
Этот код — классический Internal DSL на C++. Он чистый, типизированный, а главное — компилятор проверяет всё на этапе сборки. Вы не сможете написать grip(123) — только true или false.
Практика 2: External DSL с помощью макросов (препроцессор)
Иногда хочется ещё более «человеческого» синтаксиса. Тут на помощь приходят макросы C++. Да, это «зло», но в умелых руках — мощный инструмент для создания DSL.
Предположим, мы хотим писать так:
ROB