Список пасхалок: https://clck.ru/33J7ck
Список пасхалок: https://clck.ru/33J7ck

Кодогенератор это программа, которая на основе исходного кода или какого-нибудь файла настроек генерирует вспомогательный код, который потом компилируется вместе с исходным кодом. Это нужно, чтобы не писать boilerplate-код (копипаст) и получить новые возможности языка.

Я делаю расширяемый кодогенератор для C++, в котором можно реализовать много полезного. Примеры модулей: перевод значений enum в строку и обратно, перевод структуры в JSON и обратно, декларативный веб-сервер, система слотов и сигналов, свой динамический полиморфизм, генератор кода для тестов...

В этом обзоре будет showcase, сравнение с другими кодогенераторами, описание работы модулей, как сделать свой модуль, и как подключить кодогенератор в свои проекты.

Почему Waffle++?

Вафли (особенно бельгийские) это настоящий boilerplate. Вафельница выпускает одни и те же изделия с минимальной разницей, как и программист пишет boilerplate-код с минимальными изменениями. Поэтому у кодогенератора такое название.

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

Enum в строку и обратно

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

Waffle++ читает особо отформатированные комментарии, чтобы сгенерировать код. Читаются комментарии формата Doxygen - это формат тулзы, которая используется для генерации HTML-страниц документации. Waffle++ читает свои "команды" по аналогии с существующими "командами" Doxygen.

Для перевода enum в строку и обратно нужно подключить статический header, где объявлены функции:

#pragma once

#include <span>
#include <string_view>

namespace Waffle {

template<typename EnumType>
EnumType FromString(std::string_view value);

template<typename EnumType>
EnumType FromStringOrDefault(std::string_view value, EnumType defaultResult);

template<typename EnumType>
std::string_view ToString(EnumType value);

template<typename EnumType>
std::span<const EnumType> GetAllEnumValues();

} // namespace Waffle

Если есть обычный enum class Color, то без кодогенератора использование данных функций с этим типом упадет во время линковки, потому что компилятор не найдет определение шаблонной функции с нужным шаблонным параметром. Нужно пометить enum командой serializable, чтобы Waffle++ "увидел" его:

// @serializable
enum class Color {
    Red,
    Green,
    Blue,
    Cyan,
    Magenta,
    Yellow,
    Black
};

Пусть этот enum находится в файле foo.h, тогда Waffle++ сгенерирует foo.enum_serializer.cpp с определениями шаблонных функций, и этот файл просто нужно будет указать в вашей системе сборки.

По умолчанию названия переводятся один к одному, то есть вызов Waffle::ToString(Color::Red) вернет строку "Red".

Но через команду stringvalue можно указать любую другую строку, или даже несколько строк ("каноничной" будет считаться первая в списке):

/*
 * @brief Represents the color of a book
 * @author Izaron
 * @serializable
 */
enum class BookColor {
    kRed, ///< @stringvalue red rot rouge
    kGreen, ///< @stringvalue green grün vert
    kBlue, ///< @stringvalue blue blau bleu
};

В примере выше команды, которые читает Doxygen (brief, author) перемешаны с командами Waffle++ (serializable, stringvalue ).

ВызовToString(BookColor::kRed) вернет "red". Вызовы FromString<BookColor>(XXX) вернут BookColor::kRed для XXX равному "red", "rot" или "rouge".

Можно посмотреть исходные enum: misc_enum_places.h, custom_names.h.

Кодогенерация: misc_enum_places.enum_serializer.cpp, custom_names.enum_serializer.cpp.

Тест с примерами: test.cpp.

Структура в JSON и обратно

Для этого модуля тоже нужно подключить статический header с двумя функциями:

#pragma once

#include <nlohmann/json.hpp>

namespace Waffle {

template<typename T>
nlohmann::json ToJson(const T& value);

template<typename T>
T FromJson(const nlohmann::json& value);

} // namespace Waffle

nlohmann/json это header-only библиотека для работы с Json в C++, чтобы не делать свои велосипеды с представлением Json.

Структуры, для которых нужны определения этих функций, надо помечать jsonable. По умолчанию в Json-представлении имена полей будут такими же, как у структуры, но этим также можно управлять через stringvalue. Файл books_library.h:

#include <optional>
#include <string>
#include <vector>

namespace model {

struct Book {
    std::string Name; // @stringvalue name
    std::string Author; // @stringvalue author
    int Year; // @stringvalue year
};

struct LatLon {
    double Lat; // @stringvalue lat
    double Lon; // @stringvalue lon
};

// @jsonable
struct Library {
    std::vector<Book> Books; // @stringvalue books
    std::optional<std::string> Description; // @stringvalue description
    LatLon Address; // @stringvalue address
};

} // namespace model

std::vector и подобные контейнеры в Json-представлении преобразуются в array. std::optional преобразуется в null, если он пустой. Метка jsonable транзитивно передается на другие структуры, если есть возможность (в примере выше Book и LatLon неявно помечены jsonable).

Кодогенерация такая: books_library.json_dump.cpp.

В тесте можно посмотреть, как Json и объекты переводятся друг в друга: test.cpp.

Генератор дата-классов

Идея data-классов позаимствована из Java-библиотеки Lombok. В Java очень популярна кодогенерация с кучей идей. В данном случае для класса генерируются геттеры, сеттеры и другие методы.

В Waffle++ для этого есть команда dataclass. В файле mountain.h:

#include <string>
#include <optional>

namespace model {

// @dataclass LatLon
struct LatLonStub {
    double latitude;
    double longitude;
};

// @dataclass Mountain
struct MountainStub {
    std::optional<std::string> name;
    std::string country; // @getteronly
    LatLonStub position;
    double peak;
};

} // namespace model

Здесь заводятся "мусорные" (неиспользуемые) структуры, а в параметре команды указывается название сгенерированного класса. Для некоторых полей можно определить, что там должны быть доступны только геттеры (то есть эти поля будет нельзя изменить).

Кодогенерация (внимание, не .cpp-файл, а .h-файл!): mountains.data_class.h.

Как видно из кодогенерации, для "больших" типов данных есть два сеттера - по const-ссылке и rvalue-ссылке:

    void SetName(std::optional<std::string>&& name) {
        name_ = std::move(name);
    }
    void SetName(const std::optional<std::string>& name) {
        name_ = name;
    }
    const std::optional<std::string>& GetName() const {
        return name_;
    }

Для "маленьких" используется обычное копирование:

    void SetPeak(double peak) {
        peak_ = peak;
    }
    double GetPeak() const {
        return peak_;
    }

Тест: test.cpp.

Мок-классы для GoogleMock

GoogleMock это библиотека для тестирования с использованием "моков" - объектов, которые имитируют поведение внешних сервисов. Лучше почитать документацию, там много информации.

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

В файле turtle.h скопирован класс из документации GoogleMock:

namespace model {

// @gmock
class Turtle {
public:
    virtual ~Turtle() = default;
    virtual void PenUp() = 0;
    virtual void PenDown() = 0;
    virtual void Forward(int distance) = 0;
    virtual void Turn(int degrees) = 0;
    virtual void GoTo(int x, int y) = 0;
    virtual int GetX() const = 0;
    virtual int GetY() const = 0;
};

} // namespace model

Waffle++ сгенерирует файл turtle.gmock.h с мок-классом, который обычно пишут вручную:

// Generated by the Waffle++ code generator. DO NOT EDIT!
// source: turtle.h

#include <gmock/gmock.h>

#include "turtle.h"

namespace Waffle {

class MockTurtle : public model::Turtle {
public:
    MOCK_METHOD(void, PenUp, (), (override));
    MOCK_METHOD(void, PenDown, (), (override));
    MOCK_METHOD(void, Forward, (int distance), (override));
    MOCK_METHOD(void, Turn, (int degrees), (override));
    MOCK_METHOD(void, GoTo, (int x, int y), (override));
    MOCK_METHOD(int, GetX, (), (const, override));
    MOCK_METHOD(int, GetY, (), (const, override));
};

} // namespace Waffle

Waffle++ устроен так, что при изменении исходного файла (то есть turtle.h) зависимый файл (то есть turtle.gmock.h) перегенерируется на этапе компиляции, не нужно будет даже запускать какие-то дополнительные команды. Позже будет описание работы с системой сборки, которые позволяют такие фокусы.

Декларативный веб-сервер

Сейчас начинаются тяжелые примеры. В движке Java Spring, который является de facto стандартом индустрии, есть кодогенерация вплоть до веб-сервера с минимумом кода. В "аннотациях" описываются обработчики HTTP-запросов, выглядит все максимально человекочитаемо:

Пример для Java, Spring Framework
@RestController
class EmployeeController {

  private final EmployeeRepository repository;

  EmployeeController(EmployeeRepository repository) {
    this.repository = repository;
  }

  @GetMapping("/employees")
  List<Employee> all() {
    return repository.findAll();
  }

  @PostMapping("/employees")
  Employee newEmployee(@RequestBody Employee newEmployee) {
    return repository.save(newEmployee);
  }

  @GetMapping("/employees/{id}")
  Employee one(@PathVariable Long id) {
    return repository.findById(id)
      .orElseThrow(() -> new EmployeeNotFoundException(id));
  }

  @DeleteMapping("/employees/{id}")
  void deleteEmployee(@PathVariable Long id) {
    repository.deleteById(id);
  }
}

При GET-запросе на http://myserver.com/employees/123 вызовется метод one(123), и в ответе на запрос вернут json-представление объекта Employee.

В нашем примере этот модуль нужно совместить с модулем json_dump, который описывался ранее:

Пример для C++, Waffle++

Файл employee.h:

#include <memory>
#include <optional>
#include <vector>

namespace model {

// @jsonable
struct Employee {
    size_t Id; // @stringvalue id
    std::string Name; // @stringvalue name
    double Salary; // @stringvalue salary
};

class IEmployeeRepository {
public:
    virtual void Add(Employee employee) = 0;
    virtual std::optional<Employee> FindById(size_t id) = 0;
    virtual std::vector<Employee> FindBySalaryRange(double lowerBound, double upperBound) = 0;
    virtual std::vector<Employee> FindAll() = 0;
    virtual void DeleteById(size_t id) = 0;
};

// @restcontroller
class EmployeeController {
public:
    EmployeeController(std::shared_ptr<IEmployeeRepository> repository)
        : repository_{std::move(repository)}
    {}

    /*
     * @brief Add a new employee
     * @postmapping /employees
     * @requestbody employee
     */
    void Add(Employee employee) {
        repository_->Add(std::move(employee));
    }

    /*
     * @brief Get the employee with given ID
     * @getmapping /employees/{id}
     * @pathvariable id
     */
    std::optional<Employee> FindById(size_t id) {
        return repository_->FindById(id);
    }

    /*
     * @brief Get all employers with salary in given range
     * @getmapping /employees/find?lowerBound={lowerBound}&upperBound={upperBound}
     * @pathvariable lowerBound
     * @pathvariable upperBound
     */
    std::vector<Employee> FindBySalaryRange(double lowerBound, double upperBound) {
        return repository_->FindBySalaryRange(lowerBound, upperBound);
    }

    /*
     * @brief Get all employees
     * @getmapping /employees
     */
    std::vector<Employee> FindAll() {
        return repository_->FindAll();
    }

    /*
     * @brief Delete the employee with given ID
     * @deletemapping /employees/{id}
     * @pathvariable id
     */
    void DeleteById(size_t id) {
        repository_->DeleteById(id);
    }

private:
    std::shared_ptr<IEmployeeRepository> repository_;
};

} // namespace model

В этом модуле есть команды restcontroller (класс-обработчик, для которого сгенерировать код) getmapping/postmapping/deletemapping (соответствующие методы HTTP-запроса), pathvariable (в переменную подставляется кусок пути), requestbody (в переменную подставляется body HTTP-запроса).

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

#pragma once

#include <memory>
#include <string>

namespace Waffle {

struct HttpRequest {
    std::string Method;
    std::string Path;
    std::string Body;
};

struct HttpResponse {
    int StatusCode;
    std::string Body;
};

template<typename Handler>
[[nodiscard]] HttpResponse ProcessRequest(Handler& handler, const HttpRequest& httpRequest);

} // namespace Waffle

Кодогенератор сгенерирует два файла: employee.json_dump.cpp и employee.rest_controller.cpp.

В большом тесте проверяется корректность обработчика HTTP-запросов: test.cpp.

Свой динамический полиморфизм

Я несколько раз пробовал сделать полиморфизм в C++ "в стиле Go".

Интерфейсы в Go работают так, что они позволяют писать один и тот же код для разных классов, которые вообще никак не связаны в родственном плане (т.е. они не "наследуются друг от друга" и т.д., в Go этого нет). В C++ есть шаблоны, но это просто заготовки кода, а не что-то нормальное.

Несколько моих подходов закончились чем-то вроде kelbon/AnyAny - ограничения C++ не позволяют отойти от реализации, похожей на эту библиотеку.

С кодогенерацией это стало намного гибче! Пример structs.h с интерфейсами:

#include <string>

namespace model {

// @polymorphic
struct Robot {
    void Forward(double distance);
    void Turn(double degrees);
    void GoTo(double x, double y);
    double GetX() const;
    double GetY() const;
};

// @polymorphic
struct Stringer {
    std::string String() const;
};

} // namespace model

Кодогенератор сделает файл structs.poly_ptr.h с несколькими классами (для T = Robot и T = Stringer):

  • poly_obj<T> - объект-враппер, который содержит что-то, что имеет такие же методы, как интерфейс. Это достигается за счет type erasure, как в std::function. Объект-враппер аллоцирует память в куче и управляет жизнью содержимого объекта.

  • poly_ref<T> - ссылка на что-то, что имеет такие же методы, как интерфейс. Быстр почти как void*, не управляет жизнью содержимого объекта.

  • poly_ptr<T> - то же самое, что poly_ref<T>, но может быть пустым (не указывать на объект).

  • const_poly_ref<T> и const_poly_ptr<T> - то же самое, что два класса выше, но доступны только константные методы интерфейса.

В тестах проверяется поведение класса, который удовлетворяет обоим интерфейсам:

class TestRobot {
public:
    void Forward(double distance) { /* ... */ }
    void Turn(double degrees) { /* ... */ }
    void GoTo(double x, double y) { /* ... */ }
    double GetX() const { /* ... */ };
    double GetY() const { /* ... */ };
    std::string String() const { /* ... */ }

    /* ... */
};

Тесты: poly_obj_test.cpp, poly_ref_test.cpp, poly_ptr_test.cpp.

Система сигналов и слотов

Сигналы и слоты используются для коммуникации между объектами. Наверное, самая популярная реализация этого паттерна реализована в Qt (документация с примерами).

У Qt в документации есть хорошее объяснение концепции, я приведу краткий пересказ:

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

Пользователь подключает статический header:

#pragma once

namespace Waffle {

class SignalBase {
public:
    virtual ~SignalBase();
};

namespace Impl {
    /* ... */
} // namespace Impl

template<typename SenderType, typename ReceiverType, typename... Args>
void Connect(const SignalBase* sender, void(SenderType::*signal)(Args...),
             const SignalBase* receiver, void(ReceiverType::*slot)(Args...))
{
    /* ... */
}

} // namespace Waffle

Каждый объект, у которого есть метод-"сигнал" или "слот", должен наследоваться от SignalBase. В его деструкторе происходит "разрегистрация" объекта, чтобы никто не вызвал метод-"слот" у уже уничтоженного объекта.

Реализация класса в counter.h, почти такая же как в документации Qt:

#include <string>

#include <waffle/modules/signals/signals.h>

namespace model {

class Counter : public Waffle::SignalBase {
public:
    Counter();

    int Value() const;

    // @slot
    void SetValue(int value);

    // @signal
    void ValueChanged(int newValue);

private:
    int Value_;
};

} // namespace model

В counter.cpp определяется только метод-"слот":

#include "counter.h"

namespace model {

Counter::Counter()
    : Value_{0}
{
}

int Counter::Value() const {
    return Value_;
}

void Counter::SetValue(int value) {
    if (value != Value_) {
        Value_ = value;
        ValueChanged(value);
    }
}

} // namespace model

Кодогенератор сгенерирует counter.signals.cpp с "рантаймом". Хотя лучше было бы сделать рантайм в виде отдельной библиотеки. Но все модули, которые я описываю, можно улучшать до пригодности к использованию.

Тест связывает сигнал со слотом и проверяет, что связь работает (тоже как в документации Qt):

#include <gtest/gtest.h>
#include "counter/counter.h"

TEST(Signals, Smoke) {
    model::Counter a, b;
    Waffle::Connect(&a, &model::Counter::ValueChanged,
                    &b, &model::Counter::SetValue);

    a.SetValue(12);
    ASSERT_EQ(a.Value(), 12);
    ASSERT_EQ(b.Value(), 12);

    b.SetValue(48);
    ASSERT_EQ(a.Value(), 12);
    ASSERT_EQ(b.Value(), 48);
}

Аналогичные проекты

В списке библиотек awesome-cpp нет раздела для кодогенераторов. Кодогенерация тесно связана с рефлексией, поэтому можно посмотреть на библиотеки из списка Reflection. Их объединяют общие черты:

  • Малая область действия, например всего лишь перевод из enum в строку и обратно.

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

  • В некоторых случаях - попытка дать пользователю "полную рефлексию", что вряд ли в принципе реализуемо. Например, в Clang для описания разных сущностей C++ используются сотни классов. Пример 1, пример 2.

В целом про рефлексию можно почитать в одной из моих прошлых статей, если нравятся сложные темы языка.

У меня нет большого опыта в Qt, но выглядит, что на Waffle++ похож его Meta-Object Compiler - там по похожему принципу генерируются .cpp-файлы (на основе магических макросов).

Хороший пример кодогенерации на основе своего DSL есть у protobuf.

Некоторые проприетарные IDE имеют возможность что-то сгенерировать в коде проекта - пример с google mock.

Устройство кодогенератора: Общее

Исходники проекта находятся на GitHub: Izaron/WafflePlusPlus. Посмотрим на структуру директории src/:

  • bin/ - создание бинарника кодогенератора wafflec со всеми модулями.

  • cmake_scripts/ - набор функций CMake, чтобы было удобнее управлять кодогенерацией.

  • include/waffle/modules/ - статические header-ы, которых нужно подключать пользователям определенных модулей.

  • lib/ - общие библиотеки кодогенератора.

  • modules/ - реализация модулей.

  • thirdparty/include/ - header-only библиотеки, используемые кодогенератором.

Посмотрим на зависимости. Проект зависит от внешних библиотек:

  • GoogleTest - для тестирования.

  • LibClang - для чтения исходников C++.

  • pantor/inja - шаблонизатор (header-only библиотека).

  • nlohmann/json - работа с JSON (header-only библиотека). Шаблонизатор тоже зависит от этой библиотеки.

Самой важной библиотекой является LibClang. За ее счет происходит весь банкет, потому что она умеет парсить исходный код на C++ в абстрактное синтаксическое дерево (AST), с которым удобно работать. Пример AST на коде с комментариями можно увидеть здесь - godbolt.

Посмотрим на структуру src/lib/ по отдельным библиотекам:

  • comment/ - вытаскивает doxygen-style комментарии, относящиеся к определению (класса/енама/функции и т.д.)

  • driver/ - здесь есть определение int main(). Разбирает аргументы командной строки, парсит файл, вызывает доступные модули для кодогенерации.

  • file/ - методы для создания генерируемого файла.

  • registry/ - макрос для регистрации модуля, хранилище модулей.

  • string_util/ - нехитрые строковые алгоритмы.

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

Устройство кодогенератора: Отдельный модуль

Теперь можно в src/modules/ посмотреть на какой-нибудь один модуль. Структура у них одинаковая. Модуль можно написать как угодно, но лучше делать это в структурированном виде. Для примера возьмем src/modules/google_mock/:

  • template.cpp - шаблон генерируемого файла. В нашем случае такой:

// Generated by the Waffle++ code generator. DO NOT EDIT!
// source: {{ source_file }}

#include <gmock/gmock.h>

#include "{{ source_file }}"

namespace Waffle {

## for struct in structs
class Mock{{ struct.name }} : public {{ struct.qualified_name }} {
public:
## for method in struct.methods
    MOCK_METHOD({{ method.return_type }}, {{ method.name }}, ({{ method.signature }}), ({{ method.qualifiers }}));
## endfor
};

## endfor
} // namespace Waffle

Этот типичный шаблон для шаблонизатора. Содержимое этого файла пойдет в качестве аргумента в библиотеку inja.

Как делается C++-строка из этого файла? Для этого в CMakeLists.txt вызывается наш метод waffle_generate_template_data(), который внутри себя вызывает утилиту xxd, из-за чего получаем в глубине build-директории файл template.cpp.data, с которым можно сделать так:

#include "template.cpp.data"
const std::string TEMPLATE{(char*)template_cpp, template_cpp_len};
  • common.h - некие общие структуры. У google_mock это всего лишь ссылка на объявление класса:

using StructDecl = const clang::CXXRecordDecl*;
using StructDecls = std::vector<StructDecl>;
  • collector.cpp/.h - сборщик данных. Также он должен вернуть список используемых команд (LibClang нужно об этом знать, без этого он не распарсит комментарии):

StructDecls Collect(clang::ASTContext& ctx);
std::vector<std::string_view> Commands();

Исходник collector.cpp достаточно простой, мы используем clang::RecursiveASTVisitor, чтобы найти все классы помеченные командой // @gmock. В объекте clang::ASTContext содержится вся информация о распарсенном C++-файле.

  • printer.cpp/.h - создатель генерируемого файла. Он берет template.cpp, заполняет json-таблицу, и пихает эти две штуки в шаблонизатор, и записывает получившееся в файл:

void Print(Context& ctx, const StructDecls& decls);

(Context это данные про текущий анализируемый файл)

struct Context {
    IFileManager& FileManager;
    std::string_view InFile;
    clang::ASTContext& AstContext;
};
  • register.cpp - регистрация модуля с названием gmock:

using namespace Waffle;

static void Do(Context& ctx) {
    if (const auto decls = GoogleMock::Collect(ctx.AstContext); !decls.empty()) {
        GoogleMock::Print(ctx, decls);
    }
}

REGISTER_MODULE(gmock, GoogleMock::Commands(), Do);

Устройство кодогенератора: Трехуровневое тестирование

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

Чтобы дальше было понятнее, сначала посмотрим, как собирается бинарник wafflec со всеми модулями. Файл src/bin/CMakeLists.txt:

if(NOT DEFINED MODULES)
    list(APPEND MODULES
        data_class
        enum_serializer
        google_mock
        json_dump
        poly_ptr
        rest_controller
        signals)
endif()

include(waffle)
waffle_add_executable(wafflec "${MODULES}")

Понятно, что в тестах достаточно собрать бинарник только с одним модулем.

Посмотрим на структуру директории src/modules/google_mock/test:

  • CMakeLists.txt - кодогенерация и сборка теста.

  • test.cpp - юнит-тест с использованием кодогенерированных файлов.

  • turtle/turtle.h - исходный файл.

  • turtle/turtle.gmock.h - кодогенерированный файл.

Посмотрим по частям CMakeLists.txt:

# generate new files
include(waffle)
waffle_add_executable(google_mock_wafflec google_mock)
waffle_generate(
    google_mock_wafflec
    turtle/turtle.h
    ${CMAKE_CURRENT_SOURCE_DIR}/turtle/turtle.gmock.h)

Мы сделали бинарник google_mock_wafflec с одним модулем google_mock, и потом даем команду сгенерировать на основе turtle.h файл turtle.gmock.h.

Тем, кто непривычен к системам сборки (как CMake) нужно понимать, что "программирование" в системах сборки не императивное (как в языке по типу C++), а каузальное. То есть CMake описывает в основном не последовательность действий, а некие готовые "цели", которые могут по-разному зависеть от других "целей" (и вообще нетривиальным образом на все влиять).

Функция waffle_generate внутри себя задает такую каузальную связь, что файл turtle.gmock.h будет пересоздаваться каждый раз при изменении turtle.h, причем именно в стадии сборки. Это как раз то, что нужно от кодогенератора.

Обратите внимание, что в третьем аргументе мы явно задали префикс, куда хотим сохранить сгенерированный файл: ${CMAKE_CURRENT_SOURCE_DIR}. Это значит, что файлы генерируются в директории исходников, и все их изменения попадут в дифф коммитов Git.

Можно задать префикс ${CMAKE_CURRENT_BINARY_DIR}, тогда файл будет генерироваться только в директории сборки, и не попадет в сам репозиторий. Оба подхода хороши для разных кейсов.

# link new files with test
add_executable(google_mock_test
    test.cpp
    ${CMAKE_CURRENT_SOURCE_DIR}/turtle/turtle.gmock.h)
target_link_libraries(google_mock_test gtest_main gmock_main)

Бинарь тестов google_mock_test зависит от turtle.gmock.h. Таким образом, при сборке "цели" google_mock_test будет запущен кодогенератор, если этого файла еще нет (или исходник turtle.h поменялся).

include(GoogleTest)
gtest_discover_tests(google_mock_test)
enable_testing()

Это стандартный boilerplate для всех тестов.

Таким образом, получаем трехуровневое тестирование:

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

  2. Получившиеся .h/.cpp-файлы сразу компилируются. Нельзя закоммитить некомпилирующиеся сгенерированные файлы.

  3. Сгенерированные файлы после компиляции проверяются в юнит-тестах. Нельзя закоммитить поломанное поведение у сгенерированных файлов.

Как использовать кодогенератор в своем проекте

В данный момент Waffle++ пока не опробован в больших проектах, поэтому оптимальный метод подключения может измениться. В СMake подключить внешний проект можно кучей разных способов. Посмотрим, как это сделать со сборкой Waffle++ с нуля.

Сначала нужно в корневом CMakeLists.txt включить создание файла compile_commands.json в корне build-директории. В этом файле описываются все настройки, нужные для компиляции каждого .cpp-файла. Кодогенератор загружает этот файл, чтобы знать настройки компиляции.

set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

Waffle++ можно загрузить прямо из GitHub:

include(FetchContent)
FetchContent_Declare(
  waffle_plus_plus
  GIT_REPOSITORY https://github.com/Izaron/WafflePlusPlus.git
  GIT_TAG main
)
FetchContent_MakeAvailable(waffle_plus_plus)

Подключим его библиотеки, include-директорию (для статических header-ов) и cmake-скрипт с функциями:

include_directories(${waffle_plus_plus_SOURCE_DIR}/src/include)
add_subdirectory("${waffle_plus_plus_SOURCE_DIR}/src")
list(APPEND CMAKE_MODULE_PATH "${waffle_plus_plus_SOURCE_DIR}/src/cmake_scripts")

И теперь в вашем проекте в CMakeLists.txt какой-нибудь библиотеки можно использовать кодогенератор:

include(waffle)
waffle_generate(
    wafflec  # используется бинарь со всеми модулями, но можно сделать свой бинарь
    piece.h
    ${CMAKE_CURRENT_BINARY_DIR}/piece.enum_serializer.cpp)

add_library(piece piece.cc board_piece.cc piece_registry.cc piece_or_empty.cc ${CMAKE_CURRENT_BINARY_DIR}/piece.enum_serializer.cpp)

Единственное - может потребоваться в корневом CMakeLists.txt "найти" LibClang:

# add Clang
find_package(Clang REQUIRED CONFIG)

That's all Folks!

Кодогенератор можно развивать в разных направлениях:

  • Расширения списка модулей - ORM для баз данных, рандомайзер структур, etc...

  • Python-скрипт для создания своего модуля, чтобы не делать много ручной работы, как сделано в clang-tidy.

  • Доделывание фичей, фикс багов, патчи в Clang (там есть где доработать парсинг комментариев).

  • Внедрение в существующие проекты.

  • Файлы настроек у модулей - например, чтобы управлять стилем функций, названиями неймспейсов и т.д.

  • Поддержка в разных системах сборки (кроме CMake) и ОС (кроме Linux).

  • Интеграция с IDE.

... Все зависит от актуальности проекта в будущем.

Реклама

Подписывайтесь на мой канал про C++ и компиляторы: https://t.me/cxx95, где я пишу контент, который, без ложной скромности, сложно найти где-то еще =)

Поставьте звездочку у Izaron/WafflePlusPlus, если вам интересно следить за проектом!

Комментарии (11)


  1. Keeper9
    19.01.2023 09:32
    +1

    Clang, WWWW, 6/4, Flying, haste, double strike

    Офигеть, дайте два.

    Такие-то статы при таком манакосте -- это вообще законно?


  1. ionicman
    19.01.2023 09:50

    Можно вопрос от неспециалиста в C++?

    А почему нельзя просто заинклюдить библиотеку и вызывать оттуда нужные фии?

    Чем лучше писать какие-то сущности в комментариях, которые потом развернутся в код?


    1. Izaron Автор
      19.01.2023 10:16

      Это особенность работы с шаблонами в C++. Пусть есть объявление такой шаблонной функции:

      template<typename EnumType>
      EnumType FromInt(int value);

      Тогда, подставляя вместо EnumType разные типы данных (например FromInt<Color>(100500), мы получим разные функции - по одной функции на каждый EnumType. Шаблонная функция (и вообще шаблонный код) это просто заготовка реального кода.

      Вывод компилятора с двумя функциями

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

      В какой-то совсем внешней библиотеке эти определения находиться не смогут, потому что внешние библиотеки не имеют никакого понятия про типы данных в конкретной программе. Пусть внутри программы есть тип enum Foo, тогда программа должна сама заботиться о наличии определения функции FromInt<Foo>.


      1. ionicman
        19.01.2023 10:35

        В какой-то совсем внешней библиотеке эти определения находиться не смогут

        Да, но если вдруг понадобиться в этой библиотеке обратиться к какой-то шаблонной фии с каким-то типом, что мешает просто заинклюдить файл с этой фй и вызвать ее с нужным типом?

        Я правильно понимаю, что у нас есть некая шаблонная функция?

        Ну для примера пусть будет minimum:

        template <typename T> T minimum (const T &a, const T &b) {
          return a < b ? a : b;
        }
        

        Есть внешний файл, где она объявлена, допустим utils.cpp

        Если понадобилось вызвать эту фю для какого-либо произвольного типа где-либо в другом файле, можно же сделать так:

        #include "utils.cpp"
        typedef unsigned int MYTYPE;
        MYTYPE a = 5, b = 7;
        cout << minimum( a, b );

        Или я задачу не верно понял?

        Кодогенератор это программа, которая на основе исходного кода или какого-нибудь файла настроек генерирует вспомогательный код, который потом компилируется вместе с исходным кодом. Это нужно, чтобы не писать boilerplate-код (копипаст)

        Или на примере того-же мока - напиши его один раз, вынеси в отдельный файл - и где надо - заинклюдь чтобы не было boilerpate-а.


        1. Sazonov
          19.01.2023 18:40
          +1

          Начните с основ. Почитайте про то что такое препроцесстрование, компиляция и линковка.

          После - чем объявление функции отличается от определения. Потом про шаблонные функции. Вы найдете ответы на все ваши вопросы. Пока лишь создаётся впечатление что вы очень плохо понимаете то, о чём спрашиваете.


        1. thevlad
          20.01.2023 02:04

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

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


    1. Sazonov
      19.01.2023 10:18

      Не совсем понял, по вашему развернутся в код кем и когда? И что значит заинклюдить библиотеку?

      Инклюдятся заголовочные файлы библиотеки (если речь не о новомодных модулях из последнего стандарта). "Вызов функций" происходит во время работы приложения, а нам же надо во время написания кода, до компиляции.


    1. Kelbon
      19.01.2023 10:21

       почему нельзя просто заинклюдить библиотеку и вызывать оттуда нужные фии?

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


  1. nckma
    19.01.2023 13:54

    Я наверное ретроград какой-то, но кодогенераторы (например, Qt moc) и всякие искуственные слоты и сигналы вызывают у меня отторжение.


    1. Izaron Автор
      20.01.2023 00:11

      Можно считать, что сам C++ это тоже кодогенератор для ассемблера.


  1. Helltraitor
    19.01.2023 17:11
    +1

    Кодогенераторы схожи с надмножествами языков программирования. Тут, обратная совместимость (без учета генерации) сохраняется за счет комментариев, ну и это в целом аналогично кодогенераторам на Qt (как написали выше).

    На данный момент возможности реализовать это в макросах нет?

    P. S. Rust serde передает привет