C++Builder реально классная штука: кидаешь форму мышкой, накидываешь кнопок, дважды кликаешь и пишешь обработчик.

Но однажды я задал себе простой вопрос. Эта про технологию или про продукт?

И решил проверить. Так появился OpenRTL, свободная реализация ядра RTL/VCL-парадигмы на стандартном C++17. Без проприетарных компиляторов, без закрытых библиотек, без лицензий.

То, на что у Embarcadero ушли десятилетия, воспроизводится примерно за 1500 строк, если понимаешь парадигму. Не потому что я гений. А потому что всех убедили, что это сложно. На самом деле не сложно. Просто никто не брался.

Почему это вообще важно

C++Builder Professional стоит $1,599. RAD Studio Architect стоит $4,199. Причём C++Builder дороже Delphi, а умеет меньше. Embarcadero продаёт не технологию. Технология старая и давно не секрет. Они продают монополию. Потому что альтернативы нет.

Есть Lazarus и Project Fresnel. Они ломают монополию, но идут со стороны Pascal и отказываются от VCL-парадигмы. Мне хотелось другого. Сохранить саму VCL-парадигму, но реализовать её на стандартном C++. Но была и вторая проблема, серьёзнее лицензий.

Как устроена VCL

Прежде чем говорить про реализацию, напомню, из чего вообще состоит VCL

Иерархия. Всё начинается с TObject, это корень. У него есть ClassName() и InheritsFrom() — ручной RTTI, без typeid и dynamic_cast как основы. Дальше идёт TComponent: всё, что может чем-то владеть. Потом TControl: всё, что можно показать на экране. Потом TOSControl, TCustomForm, TForm, и уже от них конкретные контролы: TButton, TLabel, TEdit, TComboBox, TCheckBox, TPanel.

Владение. TComponent владеет другими TComponent через Owner и OwnedComponents. Кнопка может принадлежать форме, значит форма её удалит. Это не то же самое, что родитель на экране, и путать их не надо.

Визуальная иерархия. TControl знает своего Parent и список ChildControls. Это про то, кто кого рисует и кто в кого попадает мышью. В VCL это две разные вещи, и это принципиально. Кнопка может принадлежать форме (форма её удалит), но визуально лежать на панели. В Qt это слито в QObject::parent. В VCL же это разделено.

События. OnClick, OnChange, OnClose, OnMouseDown, OnKeyDown. В C++ это std::function, в Object Pascal method pointer. Событие это просто поле. Его можно назначить, переназначить, очистить. Ничего особо нового.

Сгенерированный код. IDE генерирует класс формы: глобальный Application, глобальный Form1, _tWinMain с Application->Initialize(), Application->CreateForm(Form1), Application->Run(). Конструктор TForm1 создаёт контролы и настраивает свойства. Всё остальное лежит в DFM-файле, который IDE читает и пишет.

Что здесь важно для нас. Всё это идеи. Ни одна из них не привязана к Win32, к конкретному компилятору или к конкретному вендору. TObject это просто класс. Owner это просто указатель. OnClick это просто функция. Иерархия это просто наследование. Всё это можно реализовать на стандартном C++.

А теперь поехали: VCL не должен знать про Win32

C++Builder это инструмент только под Windows, и VCL тоже завязан на Windows. TWinControl знает про HWND, TCanvas знает про HDC, TForm знает про WndProc. Перенести это на Linux без переписывания половины кода не выйдет. Поэтому Embarcadero и не делает VCL кроссплатформенным: у них это надстройка над Win32, а не самостоятельная парадигма. FireMonkey они писали с нуля, потому что VCL под другие системы уже не переделать

В этом и есть главная идея OpenRTL

VCL-слой не знает, под какой ОС он работает. Совсем. TControl не знает про HWND. TForm не знает про WndProc. TCanvas не знает про HDC. Всё, что VCL знает про платформу, это абстрактный интерфейс IOSDriver.

class IOSDriver {
public:
    virtual ~IOSDriver() = default;

    virtual std::unique_ptr<IOSHandle>
        CreateControl(const ControlDesc& d) = 0;
    virtual std::unique_ptr<TCanvas>
        CreateCanvas(IOSHandle* h) = 0;

    virtual const char* Name() const = 0;
    virtual int  RunMessageLoop() = 0;
    virtual void SetBounds(IOSHandle* h, int l, int t, int w, int ht) = 0;
    virtual void SetVisible(IOSHandle* h, bool v) = 0;
    virtual void SetText(IOSHandle* h, const std::string& text) = 0;
    virtual std::string GetText(IOSHandle* h) const = 0;
    virtual void SetEnabled(IOSHandle* h, bool e) = 0;
    virtual void Invalidate(IOSHandle* h) = 0;

    virtual void SetCheck(IOSHandle* h, bool c) = 0;
    virtual bool GetCheck(IOSHandle* h) const = 0;
    virtual void AddString(IOSHandle* h, const std::string& s) = 0;
    virtual void SetSel(IOSHandle* h, int idx) = 0;
    virtual int  GetSel(IOSHandle* h) const = 0;
    virtual void SetEventSink(IOSHandle* h, IEventSink* sink) = 0;
};

С другой стороны - IEventSink. Это то, как ОС сообщает VCL, что что-то произошло

struct OSEvent {
    enum Type {
        MouseDown, MouseUp, MouseMove,
        KeyDown, KeyUp,
        Resize, Move, Close, Paint,
        Show, Hide, Command, Change
    };
    Type type = Paint;
    int  x = 0, y = 0;
    int  button = 0;
    int  key = 0;
    int  width = 0, height = 0;
    uint32_t timestamp = 0;
    bool cancel = false;
};

class IEventSink {
public:
    virtual ~IEventSink() = default;
    virtual void OnOSEvent(OSEvent& e) = 0;
};

OSEvent это нормализованное событие

Не WM_PAINT, не WM_LBUTTONDOWN. Просто Paint, MouseDown. Платформа сама обязана перевести своё сообщение в этот формат.

Между ними находится IOSHandle. Это непрозрачный указатель на то, что ОС считает окном

struct IOSHandle {
    virtual ~IOSHandle() = default;
};

VCL не знает, что внутри. Для Win32 это HWND. Для GTK4 это GtkWidget. Для Cocoa это NSView. Она просто передаёт его драйверу и говорит: сделай с ним вот это

Как это выглядит в цепочке

Win32 message (WM_COMMAND)
->
WndProc (Win32-специфичный)
->
Win* из GWLP_USERDATA (Win32-специфичный)
->
ITWindowsDriver::OnCommand (Win32-специфичный)
// ===========> Переходная граница 
Emit() → OSEvent{type=Command}
->
IEventSink::OnOSEvent(OSEvent)
->
TControl::OnOSEvent
->
FOnClick

Выше границы живёт Win32. Ниже границы живёт чистый VCL. TControl не знает, что WM_COMMAND существует. Он знает только, что пришёл OSEvent::Command

Как это работает на практике

virtual void CreateHandle(IOSHandle* parentHandle) {
    if (FHandle) return;
    if (!FDriver) return;

    ControlDesc d;
    d.kind = Kind();
    d.caption = FCaption;
    d.x = FLeft; d.y = FTop; d.w = FWidth; d.h = FHeight;
    d.visible = FVisible;
    d.enabled = FEnabled;
    d.id = FId;
    d.parent = parentHandle;

    FHandle = FDriver->CreateControl(d);
    //...
}

TOSControl формирует описание контрола, ControlDesc, и отдаёт его драйверу. Что драйвер с ним сделает, уже не его дело. TWindowsDriver вызовет CreateWindowExW. TGTKDriver вызовет gtk_button_new. TCocoaDriver вызовет [[NSButton alloc] init]. TOSControl об этом никогда не узнает.

void OnOSEvent(OSEvent& e) override {
    switch (e.type) {
    case OSEvent::KeyDown: {
        int key = e.key;
        if (FOnKeyDown) FOnKeyDown(this, key, 0);
        break;
    }
    case OSEvent::Change:
        if (FOnChange) FOnChange(this);
        break;
    case OSEvent::Close:
        if (FOnClose) FOnClose(this, e.cancel);
        return;
    default:
        inherited::OnOSEvent(e);
        return;
    }
}

С событиями та же логика. TOSControl не разбирает WM_KEYDOWN. Он получает OSEvent::KeyDown с полем key. Откуда взялся key, из Win32 WPARAM, из GTK GdkEventKey::keyval или из Cocoa NSEvent::keyCode, не важно. Драйвер уже нормализовал

Что это даёт

  1. Кроссплатформенность не потом, а по архитектуре.

Я не пишу #ifdef _WIN32 в VCL-слое. Совсем. Потому что VCL-слой не знает, что такое Windows. Если завтра я напишу TGTKDriver, VCL-слой не изменится ни на строку. Достаточно реализовать IOSDriver для GTK4.

  1. Win32 можно заменить, не трогая VCL.

Если завтра Microsoft поменяет Win32 на что-то другое (шутка, но всё же), я перепишу TWindowsDriver, а VCL останется. Если я захочу использовать WinRT или UWP, перепишу драйвер.

  1. Отладка становится проще.

Когда что-то не работает, я точно знаю, где искать. Проблема с отрисовкой? Либо TCanvas в VCL, либо TWindowsCanvas в драйвере. Проблема с событием? Либо TControl::OnOSEvent в VCL, либо Emit в драйвере. Граница проходит по OSEvent, и это точка разреза.

  1. Можно тестировать VCL без ОС.

В теории можно написать TNullDriver, который ничего не создаёт, но логирует вызовы. И тестировать логику VCL без единого окна. Я этого пока не сделал, но архитектура это позволяет. Это прямое следствие того, что VCL не знает про Win32.

Как выглядит драйвер

Здесь весь Win32

Вот код, который превращает ControlDesc в реальное окно Win32. Это сердце TWindowsDriver

std::unique_ptr<IOSHandle> CreateControl(const ControlDesc& d) override {
    HWND parent = d.parent ? static_cast<Win*>(d.parent)->hwnd.get() : nullptr;

    DWORD style = 0, exStyle = 0;
    const wchar_t* cls = nullptr;

    switch (d.kind) {
    case ControlKind::Form:
        cls = L"VCLFormClass";
        style = WS_OVERLAPPEDWINDOW;
        EnsureClass(cls);
        break;
    case ControlKind::Panel:
        cls = L"VCLPanelClass";
        style = WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN | WS_CLIPSIBLINGS;
        EnsureClass(cls);
        break;
    case ControlKind::Label:
        cls = L"STATIC";
        style = WS_CHILD | WS_VISIBLE | SS_LEFT;
        break;
    case ControlKind::Button:
        cls = L"BUTTON";
        style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON;
        break;
    case ControlKind::CheckBox:
        cls = L"BUTTON";
        style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX;
        break;
    case ControlKind::Edit:
        cls = L"EDIT";
        style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_LEFT | ES_AUTOHSCROLL;
        exStyle = WS_EX_CLIENTEDGE;
        break;
    case ControlKind::ComboBox:
        cls = L"COMBOBOX";
        style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL;
        break;
    case ControlKind::ListBox:
        cls = L"LISTBOX";
        style = WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY;
        exStyle = WS_EX_CLIENTEDGE;
        break;
    }

    if (!d.visible) style &= ~WS_VISIBLE;
    if (!d.enabled) style |= WS_DISABLED;

    int x = d.x, y = d.y, ww = d.w, hh = d.h;
    if (d.kind == ControlKind::Form) {
        RECT r{ 0, 0, ww, hh };
        AdjustWindowRectEx(&r, style, FALSE, exStyle);
        ww = r.right - r.left;
        hh = r.bottom - r.top;
    }

    int ctrlId = 0;
    if (d.kind != ControlKind::Form && d.kind != ControlKind::Panel)
        ctrlId = (d.id ? d.id : FNextId++);

    return CreateWin(d, cls, style, exStyle, parent, x, y, ww, hh, ctrlId);
}

std::unique_ptr<Win> CreateWin(const ControlDesc& d,
    const wchar_t* cls,
    DWORD style, DWORD exStyle,
    HWND parent,
    int x, int y, int w, int h,
    int ctrlId)
{
    auto win = std::make_unique<Win>();
    win->driver = this;
    win->kind = d.kind;
    win->id = d.id;
    win->isForm = (d.kind == ControlKind::Form);

    win->hwnd.reset(::CreateWindowExW(
        exStyle, cls, Utf8ToW(d.caption).c_str(), style,
        x, y, w, h, parent, (HMENU)(INT_PTR)ctrlId,
        FInst, nullptr));

    if (!win->hwnd) return nullptr;

    AttachWin(win->hwnd.get(), win.get());
    return win;
}

CreateControl достаёт родительский HWND из IOSHandle. d.parent - это IOSHandle*, внутри которого лежит Win*, а внутри Win — сам HWND. VCL-слой об этом не знает: он передал непрозрачный указатель, драйвер сам разобрался, что с ним делать.

Теперь собственно создание окна

std::unique_ptr<Win> CreateWin(const ControlDesc& d,
    const wchar_t* cls,
    DWORD style, DWORD exStyle,
    HWND parent,
    int x, int y, int w, int h,
    int ctrlId)
{
    auto win = std::make_unique<Win>();
    win->driver = this;
    win->kind = d.kind;
    win->id = d.id;
    win->isForm = (d.kind == ControlKind::Form);

    win->hwnd.reset(::CreateWindowExW(
        exStyle, cls, Utf8ToW(d.caption).c_str(), style,
        x, y, w, h, parent, (HMENU)(INT_PTR)ctrlId,
        FInst, nullptr));

    if (!win->hwnd) return nullptr;

    AttachWin(win->hwnd.get(), win.get());
    return win;
}

Win - это структура-обёртка над HWND. Она хранит сам хендл, указатель на драйвер, IEventSink, тип контрола и id.

Это нужно, чтобы WndProc мог достать Win* из HWND и передать сообщение дальше - драйверу, а тот уже отправит его в VCL как OSEvent

CreateControl - это большой switch по ControlKind. Каждому типу контрола соответствует свой системный класс Win32 (BUTTON, EDIT, STATIC, COMBOBOX) и набор стилей (WS_*, BS_*, ES_*, CBS_*). VCL передаёт ControlDesc, драйвер переводит его в вызов CreateWindowExW. Для формы дополнительно вызывается AdjustWindowRectEx — она переводит клиентские размеры в размеры окна с рамкой. После создания окна указатель на Win записывается в GWLP_USERDATA, чтобы WndProc мог найти драйвер. Никакие Win32-константы наружу не уходят: VCL знает только ControlKind::Button, а не BS_PUSHBUTTON

void OnCommand(HWND, Win*, WORD code, HWND child, WORD) override {
    if (!child) return;
    auto* sink = SinkForHwnd(child);
    if (!sink) return;

    OSEvent e; e.key = (int)code;
    switch (code) {
    case BN_CLICKED:
        e.type = OSEvent::Command;
        sink->OnOSEvent(e);
        break;
    case CBN_SELCHANGE:
    case EN_CHANGE:
        e.type = OSEvent::Change;
        sink->OnOSEvent(e);
        break;
    }
}

Как это выглядит целиком

class TForm1 : public TForm {
    INHERITED(TForm);
public:
    explicit TForm1(TComponent* owner) : TForm(owner) {
        SetCaption("Hello VCL (Win32)");
        SetBounds(200, 200, 480, 320);

        OnClose() = [](TObject*, bool& CanClose) {
            int r = MessageBoxW(nullptr, L"Точно закрыть приложение?",
                L"Подтверждение", MB_YESNO | MB_ICONQUESTION);
            CanClose = r == IDNO;
        };

        auto* panel = new TPanel(this);
        panel->SetParent(this);
        panel->SetBounds(10, 10, 460, 80);

        auto* label = new TLabel(panel);
        label->SetParent(panel);
        label->SetBounds(20, 30, 400, 24);
        label->SetCaption("Press the button!");

        auto* button = new TButton(this);
        button->SetParent(this);
        button->SetBounds(20, 120, 160, 40);
        button->SetCaption("Click me");
        button->OnClick() = [label](TObject*) {
            label->SetCaption("Clicked at " + std::to_string(GetTickCount64()));
        };
    }
};

Иерархия вызовов

Win32 message
    => WndProc
        => Win* (GWLP_USERDATA)
            => ITWindowsDriver::OnXxx()
                => Emit() → IEventSink::OnOSEvent(OSEvent)
                    => TControl::OnOSEvent / TOSControl::OnOSEvent
                        => FOnClick / FOnChange / FOnKeyDown

RTL-слой полностью идиоматичен точке входу самой RAD Studio

На момент написания этой статьи RTL-слой реализован полностью: TObject, TComponent, Exception, иерархия исключений, владение. Всё, что есть в настоящем RTL. А сгенерированный класс формы (TForm1) полностью совместим и похож на то, что генерирует RAD Studio, вплоть до Application, Form1, _tWinMain и порядка инициализации. Разница только в архитектуре под капотом.

Это ровно тот код, который сгенерировала бы RAD Studio, если бы у неё был такой бэкенд. Application, Form1, _tWinMain, порядок инициализации, способ создания контролов через new + SetParent + SetBounds, лямбды вместо published-методов. Всё это совместимо и похоже на оригинальный сгенерированный код. Если ты писал на C++Builder, ты узнаешь этот код с первого взгляда.

// Глобальные объекты - как в IDE VCL
std::unique_ptr<TApplication> Application;
TForm1* Form1 = nullptr;

// _tWinMain - билдеровский вход в стиле IDE.
int WINAPI _tWinMain(HINSTANCE, HINSTANCE, LPTSTR, int)
{
    try
    {
        Application->Initialize();
        Application->MainFormOnTaskBar = true;
        Application->CreateForm(Form1);
        Application->Run();
    }
    catch (Exception& exception)
    {
        Application->ShowException(&exception);
    }
    catch (...)
    {
        try
        {
            throw Exception("");
        }
        catch (Exception& exception)
        {
            Application->ShowException(&exception);
        }
    }
    return 0;
}

//  wWinMain - настоящая точка входа CRT.
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int)
{
    TWindowsDriver driver(hInstance);
    Application = std::make_unique<TApplication>(nullptr);
    Application->SetDriver(&driver);
    Application->SetTitle("VCL Demo");
    Form1 = new TForm1(Application.get());
    return _tWinMain(hInstance, nullptr, nullptr, 0);
}

Полный исходный код проекта собирается обычной студией MSVC

Ссылка на GitHub-проект

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