Привет, Хабр! Меня зовут Константин Крюков, я разрабатываю систему хранения данных TATLIN.UNIFIED в YADRO. Сейчас мы с командой создаем MeyerSAN — решение, которое имитирует неисправность SAS HDD и SSD и позволяет автоматически тестировать реакцию СХД на ошибки.

Мы написали проект на новом стандарте С++ 23 и использовали паттерны объектно-ориентированного программирования. Под катом расскажу, что за решение у нас вышло, как устроена его архитектура. А еще мы вместе вспомним, зачем строить программную архитектуру тщательно и правильно (и не жалеть об утраченном времени на активную разработку).

Какую проблему решаем

Системы хранения данных объединяют до 600 дисков в единое хранилище, предоставляют большой объем для хранения данных и обеспечивают высокую скорость доступа к ним. При этом диски в СХД — «расходный материал». Через несколько лет эксплуатации диск может начать вести себя не так, как сразу после завода. Мы в команде называем такие диски «плохими».

Чтобы пользователь не потерял доступ к данным, а система работала стабильно, СХД должна обнаруживать такие диски, помечать их как проблемные и подменять на исправные. СХД замечает «плохие» диски, когда запросы на запись или чтение к конкретному диску возвращаются дольше обычного. К тому же диск может портить данные и метаданные, выдавать ошибки.

Когда СХД понимает, что в одном из дисков возникла проблема, она должна переключить нагрузку на другой диск или снизить нагрузку на проблемный.

И тут возникает несколько вопросов:

  • По каким критериям определять, что диск испорчен.

  • Как написать систему, которая сможет находить неисправные диски.

  • И как тестировать такую систему. 

Первое, что приходит в голову, — вставить в СХД сломанный диск и проверить, как реагирует система. Но у подхода есть большой минус: это ручное тестирование. Проводить его непрерывно и системно практически невозможно. Чтобы автоматизировать проверку дисков, мы разработали проект MeyerSAN. 

Что такое MeyerSAN 

Проект MeyerSAN — это программно-аппаратный комплекс на основе сервера VEGMAN. Он необходим для тестирования и валидации работы подсистем, которые находятся в составе СХД и определяют проблемные диски. 

Как это работает: 

  1. Мы подключаем сервер к системе хранения данных. 

  2. С помощью ПО и драйверов заставляем СХД видеть сервер как диск или несколько дисков.

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

Задача MeyerSAN — эмулировать проблемы с дисками: задержки, ошибки, порчу данных и метаданных.

Архитектура MeyerSAN состоит из трех больших блоков: 

  1. REST, так называемый MRSNMGMT. Позволяет конфигурировать систему в соответствии с пожеланиями.

  2. MRSNLib. Cодержит бизнес-логику приложения: обработку и модификацию команд.

  3. Драйверы низкого уровня. Они предоставляют нам механизмы транспорта и позволяют соответствовать всем протоколам.

В статье я сфокусируюсь на MRSNLib. Мы написали его на модном C++ 23 и очень им гордимся.

Состав архитектуры MRSNLib

Обсудим кирпичики, на которых стоит архитектура middleware-компонента. 

Команда — та самая команда к диску, которую необходимо обработать и на которую необходимо ответить. Это может быть read-команда, write-команда и любая другая. 

Модификатор — сущность, ответственная за модификацию команды. Она отклоняет диск от базового поведения.

Runtime — связующий слой, который позволяет модификаторам и командам выполняться. Это всяческие thread-pool и окружение, в котором выполняется код.

Требования к ПО

Правильное программное обеспечение пишется из требований. Наши выглядели так: 

  • На внедрение нового модификатора уходит минимум времени. Когда к нам, разработчикам MeyerSAN, приходят с запросом на новый сценарий, внедрять его нужно быстро.

  • Эмулируемые с помощью MeyerSAN диски работают со скоростью, сравнимой с реальными HDD.

  • В проекте есть две группы модификаторов: базовые и протокол-специфичные.

Подробнее о разделении

Современные жесткие диски работают по разным протоколам со своими спецификой и терминологией: SCSI, SATA, ATA, SAS, NVMe. Мы стремимся к тому, чтобы часть наших модификаторов были базовыми, то есть не зависели от протокола. Все остальные называются протокол-специфичными. Такиемодификаторы используют специфику интерфейса: это могут быть SCSI-специфичные, NVMe-специфичные и другие модификаторы.

Чтобы соответствовать требованиям, воспользуемся некоторыми архитектурными приемами, известными как паттерны объектно-ориентированного программирования. Их будет три: команда, декоратор и визитер. Я покажу, как они помогают нам в реальном проекте и обеспечивают гибкость логики. 

Команда: не зависим от runtime и удобно расширяем решение

Команда — сущность с кодом, выполняющим бизнес-логику. В нашем проекте она представлена интерфейсом с двумя методами:

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

  • Вспомогательный метод OpCode, который позволяет идентифицировать эту команду.

// Command.hpp:

class Command {
public:
    virtual OpCode opCode() const noexcept = 0;
    virtual void execute() = 0;
};

У нас есть команды read и write, которые выполняют свои функции. И благодаря тому, что мы выделяем их работу в отдельные сущности, мы завязываем логику этой команды внутрь метода:

// ReadCommand.hpp:

class ReadCommand : public Command {
private:
    void execute() override {
        std::cout << "Do READ work!";
    }
    // ...
};

// WriteCommand.hpp:

class WriteCommand : public Command {
private:
    void execute() override {
        std::cout << "Do WRITE work!";
    }
    // ...
};

Посмотрим на клиентский код. Есть API getCommands, он присылает команду к диску, которую надо реализовать. А мы просто берем каждую команду и выполняем ее метод execute. И нам абсолютно неважно, какая команда перед нами. Благодаря заключению всей логики внутрь класса и виртуализации у нас выполняется правильный код:

// main.cpp:

std::vector<sptr<Command>> getCommands();

int main() {
    for (auto cmd : getCommands())
        cmd->execute();
}

// getCommands.cpp:

std::vector<sptr<Command>> getCommands() {
    auto cmds = getCommandsFromAPI();

    // filter known commands

    return std::vector<sptr<Command>>(cmds);
}

Что делать, если нужно работать с недоступным API? В нашем случае это был низкоуровневый драйвер, который присылал команды для обработки в пользовательском пространстве. Если пока не знаете, как выполнять команды, отфильтруйте их по степени известности или по степени вашей готовности их обрабатывать. После этого передайте их на выполнение в ваш API, который в данном случае называется getCommands. 

Преимущества решения

  • Мы открыты для расширения. Если получили команду и пока не знаем, как ее обрабатывать, пишем в драйвер соответствующий ответ.

  • Не зависим от runtime. Это может быть исполнение в threadpool, в последовательном контексте, может быть и синхронное выполнение.

Декоратор: модифицируем гибко

Перейдем к следующему паттерну — декоратору. Вы могли слышать о нем, если пишете на Python, но в MeyerSAN мы используем его в необычном формате.

Перед тем, как перейти непосредственно к декоратору, попробуем спроектировать модификатор. Предположим, у нас будет некий класс Modifier: 

class Modifier { 
public:
    virtual void modify(sptr<Command>) = 0; 
};

int main() { 
    std::vector<sptr<Command>> cmds{ /* ... */ };
    auto mod = make_sptr<DelayModifier>();
    
    for (auto cmd : cmds) {
        mod->modify(cmd); // DelayModifier: modify before "execute"
        cmd->execute();
    }

    return 0; 
}

Есть метод modify, который принимает команду и ничего не возвращает. Его задача — изменить поведение команды определенным образом. В клиентском коде это выглядит так: сначала создаем модификатор, потом применяем его к команде через modify, а затем вызываем execute, чтобы команда сработала.

Проблема в том, что разные модификаторы требуют разного порядка исполнения задач. Например, есть модификатор задержки (DelayModifier), который добавляет паузу перед выполнением команды. Его нужно вызвать до execute, чтобы задержка сработала правильно. А есть другой модификатор, который портит данные при чтении — его тоже нужно применить, но уже после выполнения команды.

Возникает вопрос: как понять, когда именно вызывать execute для каждой модификации? У всех модификаторов один интерфейс, одинаковый метод modify. Но у некоторых есть особенность — они вообще не требуют вызова execute. Например, есть модификатор, который просто ставит ошибку и вообще не вызывает команду диска. В этом случае не нужно тратить ресурсы на реальный запрос к железу.

Это создает сложности: некоторые модификаторы требуют вызова modify и execute, а другие — только modify. Нужно правильно управлять порядком и знать, когда именно вызывать execute для каждой конкретной модификации:

// Первичная реализация модификации

class Modifier {
public:
    virtual void modify(sptr<Command>) = 0;
};

int main() {
    std::vector<sptr<Command>> cmds{ /* ... */ };
    auto mod = make_sptr<ReadCorruptModifier>();

    for (auto cmd : cmds) {
        cmd->execute();
        mod->modify(cmd); // ReadCorrupt: modify after "execute"
    }

    return 0;
}

Мы воспользовались декоратором, чтобы избежать этих проблем. Изменили интерфейс модификатора:

// Modifier.hpp:

class Modifier {
public:
    virtual sptr<Command> modify(sptr<Command>) = 0;
};

Теперь у нас метод modify возвращает не просто void, а команду, но уже другую:

// InquiryCorruptModifier.hpp:

class InquiryCorruptModifier : public Modifier {
private:
    class CorruptedInquiryCommand : public Command {
        sptr<Command> original_;
    	// ...
        
        void execute() override {
            // modify before (delay)
            original_->execute(); // optional call
            // modify after (corrupt)
        }
    };
public:
    sptr<Command> modify(sptr<Command> cmd) override {
        if (cmd->opCode() == OpCode::INQUIRY)
            return make_sptr<CorruptedInquiryCommand>(std::move(cmd));
        return cmd;
    }
};

Перед вами класс InquiryCorruptModifier. Inquiry — это специальная команда в протоколе SCSI, которая сообщает информацию о диске. В нашем тесте мы хотим изменить эти данные: подменить ID. Для этого создаем модификатор, который реализует интерфейс и переопределяет метод modify.

Как он работает? Мы смотрим на OpCode-команду, которая к нам пришла. Если OpCode совпадает с тем, что мы хотим изменить, то решаем, что эту команду нужно модифицировать. Тогда оборачиваем ее в специальный подкласс.

Обратим внимание на этот подкласс. В его конструкторе мы сохраняем команду, которую получили. Внутри есть вложенный класс CorruptedInquiryCommand, который реализует интерфейс команды. У него есть метод execute и сама команда.

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

Преимущества решения

  • Модифицируем через обертку. Сама концепция модификации команд у нас представлена через обертку в подклассе. Декоратор — это матрешка, а количество оберток не ограничено. Таким образом, мы поддерживаем множественную модификацию, не используя никакого дополнительного архитектурного кода. 

  • Модифицируем команды гибко. Можем модифицировать до execute, после execute, вместо execute. Код никак не зависит от других модификаторов и типа конкретной команды.

Визитер: разделяем логику и избавляемся от дублирования

Давайте рассмотрим пример, чтобы понять, зачем нужен визитер. На самом деле, пример ниже верен для протокол-специфичного кода. Когда мы работаем на базовом уровне и создаем базовые модификаторы, у них нет метода OpCode. OpCode — это понятие, характерное только для протокола SCSI.

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

// Command.hpp:

class Command {
public:
    virtual OpCode opCode() const noexcept = 0;
    virtual void execute() = 0;
};

// ReadCorruptModifier.hpp:

class ReadCorruptModifier : public Modifier {
    // ...
public:
    sptr<Command> modify(sptr<Command> cmd) override {
        if (cmd->opCode() == OpCode::READ10) // ???
            return make_sptr<CorruptedReadCommand>(std::move(cmd));
        return cmd;
    }
};

Для решения проблемы мы внедрили визитер. Для лучшего понимания я сделал диаграмму классов.

Она достаточно большая, но сейчас нас интересует классы Command и Visitor. Как изменится команда? Вместо метода OpCode у нее появится метод accept. Он принимает визитера — то есть по интерфейсу мы передаем его в команду.

Также у нас появляются классы для разных команд — например, read command, write command и другие. Эти классы нужны, чтобы определить общее поведение для похожих команд. Можно выделить их как базовые и придумать для них общие модификаторы.

Что дальше? У нас есть интерфейс визитера, который содержит виртуальные методы для каждого типа команды. Эти методы вызываются, когда команда принимает визитера, и позволят по-разному обрабатывать разные команды:

// Command.hpp:

class Command : public std::enable_shared_from_this<Command> {
public:
    virtual void execute() = 0;
    virtual sptr<Command> accept(Visitor&) = 0;
    ...
};

class ReadCommand;
class WriteCommand;
// ...

class Visitor {
public:
    virtual sptr<Command> visit(sptr<Command> cmd) = 0;
    virtual sptr<Command> visit(sptr<ReadCommand> cmd) = 0;
    virtual sptr<Command> visit(sptr<WriteCommand> cmd) = 0;
};

Посмотрим на read-команду и write-команду, то есть на реализацию интерфейса команды. Однако вместо метода OpCode появляется переопределение метода accept. Поговорим об этом чуть позже:

// IOCommand.hpp
class ReadCommand : public Command {
public:
    void execute() override { std::cout << "ReadCommand" << std::endl; }
    sptr<Command> accept(Visitor& visitor) override {
        return visitor.visit(
    	    std::static_pointer_cast<ReadCommand>(shared_from_this())
        );
    }
};

class WriteCommand : public Command {
public:
    void execute() override { std::cout << "WriteCommand << std::endl; }
    sptr<Command> accept(Visitor& visitor) override {
        return visitor.visit(
        	std::static_pointer_cast<WriteCommand>(shared_from_this())
        );
    }
};

Модификатор в таком случае выглядит довольно просто. Мы создаем визитер и передаем команду, которая к нам пришла на модификацию, в метод accept и возвращаем результат, который нам accept вернул.

// ReadDelayModifier.hpp
class ReadDelayModifier : public Modifier {
    sptr<Command> modify(sptr<Command> cmd) override {
        ReadDelayVisitor visitor; // stateless

        return cmd->accept(visitor);
    }
};

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

Конкретно в коде у нас есть несколько перегрузок метода, и только в той, которая принимает команду read, мы вставляем нашу обертку. Остальные вызовы мы оставляем как есть.

class ReadDelayVisitor : public Visitor {
    class DelayedReadCommand : public Command {
        void execute() override {
            std::cout << “ReadDelay” << std::endl;
            original_->execute();
        }
    };

public:
    sptr<Command> visit(sptr<Command> cmd) { return cmd; }
    sptr<Command> visit(sptr<ReadCommand> cmd) {
        return make_sptr<DelayedReadCommand>(std::move(cmd));
    }
    sptr<Command> visit(sptr<WriteCommand> cmd) { return cmd; }
};

Эту обертку мы уже видели раньше, но я еще раз покажу ее для ясности. Она будет последним элементом в нашей структуре классов. Назовем ее DelayedReadCommand. Это класс, который реализует интерфейс команды. Он хранит внутри оригинальную команду и в методе execute сначала вызывает execute у оригинальной команды, а потом делает что-то еще — например, добавляет задержку перед выполнением или после него:

// ReadDelayVisitor.cpp
sptr<Command> ReadDelayVisitor::visit(sptr<Command> cmd) {
    return cmd;
}

sptr<Command> ReadDelayVisitor::visit(sptr<ReadCommand> cmd) {
    return make_sptr<DelayedReadCommand>(std::move(cmd));
}

sptr<Command> ReadDelayVisitor::visit(sptr<WriteCommand> cmd) {
    return cmd;
}

Проблемы с дублированием 

В коде ниже видим дублирование в методе accept. Давайте попробуем от него избавиться, сделаем Mixin:

// IOCommand.hpp but a little bit more real
class ReadCommand : public Command {
    sptr<Command> accept(Visitor& visitor) override {
        return visitor.visit(std::static_pointer_cast<ReadCommand>(shared_from_this()));
    }
};

class WriteCommand : public Command {
    sptr<Command> accept(Visitor& visitor) override {
        return visitor.visit(std::static_pointer_cast<WriteCommand>(shared_from_this()));
    }
};

class MgmtCommand : public Command {
    sptr<Command> accept(Visitor& visitor) override {
        return visitor.visit(std::static_pointer_cast<MgmtCommand>(shared_from_this()));
    }
};

...

Mixin — это паттерн, который позволяет «примешивать» функциональность в класс. Вы пишете класс, пишете туда функциональность и наследуете набор классов от Mixin. В данном случае у нас шаблонный Mixin, он принимает command type, который надо привести в static pointer, и порождает метод accept. Метод будет наследоваться во всех классах от Mixin.

// VisitorsMixin.hpp
template<typename CommandType, typename VisitorType = Visitor>
class VisitorMixin : public Command {
    sptr<Command> accept(VisitorType& visitor) override {
        return visitor.visit(
            std::static_pointer_cast<CommandType>(shared_from_this())
        );
    }
};

class Command : public VisitorMixin<Command> {
	// ...
};

class ReadCommand : public VisitorMixin<ReadCommand> {
	// ...
};

class WriteCommand : public VisitorMixin<WriteCommand> {
	// ...
};

Но есть и другая проблема. При порождении классов command, read command, write command и любой другой команды опять возникает дублирование в паттерне CRTP. 

Давайте решать. Помимо Mixin, можем воспользоваться свежим С++ 23, в котором ввели функциональность deducing this. Она позволяет избавиться от дублирования. Как это может выглядеть в базовой реализации?

// C++23!
class proxy_shared_from_this : public std::enable_shared_from_this<proxy_shared_from_this> {
public:
    template <typename Self>
    auto get_shared_from_this(this Self& self) {
        return std::static_pointer_cast<Self>(self.shared_from_this());
    }
};

class Command : public proxy_shared_from_this {
	...
};

Напишем обертку над shared_from_this, назовем ее proxy_shared_from_this, у нее будет deducing this метод, shared_from_this или get_shared_from_this. 

В чем фишка? Self — это шаблонный параметр, в коде выше будет выведен каждый раз в тип самого производного класса, в котором вы вызвали этот метод, ровно в тип класса. Поэтому если вы совершите вызов метода accept из команды, произойдет приведение к правильному типу. И обратите внимание, мы избавились от CRTP. Теперь, когда мы порождаем команду, мы не используем опять название этого класса.

Преимущества решения

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

  • Отсутствуют методы, раздувающие интерфейс. В классе команды больше нет методов, которые не имеют отношения к логике ее работы. Благодаря паттерну визитер мы переносим тип команды из интерфейса в тип программной сущности.

  • Это позволяет не путаться с точки зрения проектирования интерфейса.

Немного рефлексии

Ничто не бывает идеальным, и наш подход — не исключение. Давайте немного поразмышляем. Ранее мы рассмотрели код модификации команд, который, однако, для простоты был упрощён. В реальной кодовой базе модификаторов гораздо больше, при этом каждый из них влияет на команду: оборачивает ее и добавляет свою логику. В результате получается цепочка вложенных оберток: каждая выполняет что-то до вызова оригинальной команды, затем вызывает ее и после этого тоже что-то делает. Такой подход создает проблему.

//main.cpp

int main() {
    std::vector<sptr<Modifier>> mods = {
    	make_sptr<ReadDelayModifier>(),
    	make_sptr<ErrorInjectingModifier>(),
        // ...
    };

    auto cmds = std::vector<sptr<Command>>{
        make_sptr<ReadCommand>(), // ...
    };

    for (auto& cmd : cmds) {
    	for (auto& mod : mods) { cmd = mod->modify(cmd); }
    }

    for (auto cmd : cmds)
        cmd->execute();
}

Рассмотрим подробнее. Внутри у нас есть реальная команда — например, чтение или запись. Ее оборачивают несколько модификаторов: один вставляет задержку, другой — инжектирует ошибку, третий — логирует действия и так далее. Каждая обертка выполняет свою логику до вызова execute, затем вызывает оригинальную команду, а после — свою дополнительную логику.

Проблема возникает в случае, если один из модификаторов решает не вызывать execute у команды вовсе. Например, задержка или ошибка могут быть реализованы только через setResult, без вызова execute. Тогда все остальные обертки ниже по цепочке тоже не сработают — команда просто не выполнится полностью.

Это мешает реализовать сценарии вроде «задержка на пять минут и затем провал команды с ошибкой», потому что в текущей архитектуре вызывается только setResult, а execute пропускается. В результате тесты или сценарии с задержками и ошибками реализовать сложно.

Решение очень простое: порядок оберток можно менять. Если сначала обернуть команду в «инжектирование ошибки», а потом в «задержку», то сначала произойдет задержка, а потом ошибка — команда уже не выполнится полностью. То есть порядок важен.

Чтобы управлять этим, нужно сортировать модификаторы по определенному критерию — например, вызывают ли они execute или нет. Тогда можно легко настроить последовательность их применения и добиться нужного поведения. Но в данной статье мы не будем рассматривать код этой логики:

//main.cpp

int main() {
    std::vector<sptr<Modifier>> mods = { /* ... */.};

    auto cmds = std::vector<sptr<Command>>{
        make_sptr<ReadCommand>(), // ...
    };

    for (auto& cmd : cmds) {
    	for (auto& mod) { cmd = mod->modify(cmd); }
    }

    for (auto cmd : cmds)
        cmd->execute();
}

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

Но в нашем случае ситуация особая: основная задача — функциональное тестирование. Мы стремимся к тому, чтобы система максимально напоминала реальный диск по функциональности и производительности. При этом критически важны быстрые запуск новых функций и поддержка новых сценариев — скорость работы системы в меньшем приоритете. Более того, MeyerSAN позволяет имитировать HDD, используя SSD для сохранения дисковых данных: таким образом мы можем компенсировать падение производительности на программном уровне.

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

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

Выводы, которые я сделал по итогам работы над MeyerSAN  

ООП — это хорошо или нет? У ООП в проекте на C++ есть свои плюсы и минусы. Минусы очевидны. Вы тратите время в runtime на абстракции и виртуализацию. Однако отсутствие правильных абстракций приводит к трудностям в поддержке проекта. Поэтому ООП — это хорошо, если согласовать его с требованиями к производительности. Если можете лишний раз пожертвовать перформансом, это отличный повод ввести удобные абстракции.

Если вы правильно используете паттерны объектно-ориентированного программирования, повышается гибкость вашего проекта. А еще его удобнее поддерживать и сопровождать в будущем.

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

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

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

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

Как относитесь к ООП в проектах на С++? Напишите в комментариях. 

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


  1. vadimr
    15.07.2025 13:05

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

    Честно говоря, с таким образом мысли, как у вас в этой статье, бросайте вы уже этот C++ и переходите на какой-нибудь функциональный язык :)


    1. KurtkaBeyn
      15.07.2025 13:05

      Верно, ещё и вечное упоминание, что надо писать "правильно", использовать "правильное" ООП, "правильную" архитектуру, очень глаз мозолит. Автор претендует на знание истины в последней инстанции


      1. kkryukov2013 Автор
        15.07.2025 13:05

        про "правильность" - как я обозначил в конце статьи, "нет универсального решения или инструмента".
        Моя мысль не в том, что строить ОО архитектуру - это единственно правильный подход
        Цель статьи - показать, как с помощью ОО подхода мы решили задачу, при этом удовлетворив как функциональным, так и нефункциональным требованиям.
        Тем не менее, слово, возможно, действительно не самое удачное :)


    1. kkryukov2013 Автор
      15.07.2025 13:05

      Добрый день!
      Я позволил себе термин "объектно-ориентированное проектирование", поскольку проект действительно состоит из объектов и классов, каждый из которых несёт единственную ответственность за опредённую функциональность: например, чтение данных с диска или модификацию выполненной команды.
      Поэтому думаю, что в данном случае термин оправдан :)


    1. Yura_PST
      15.07.2025 13:05

      В статье описано применение шаблонов ООП на практике, которым уже много десятков лет. Причем здесь "функциональное проектирование" не понятно.


      1. vadimr
        15.07.2025 13:05

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


        1. eao197
          15.07.2025 13:05

          Зато связка из Command и Visitor-а там вот прям типичное ООП, да еще в варианте со статической типизаций. Или вы скажете что подобные вещи в функциональном подходе аналогичным образом решаются?


          1. vadimr
            15.07.2025 13:05

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

            Как раз ООП тут не очень подходит по той причине, которую Вы сами указали в комментарии ниже.


            1. eao197
              15.07.2025 13:05

              Это просто ассоциативный список лямбда-выражений.

              Что список?

              В статье Command и Visitor решают задачу применения M методов обработки к K вариантам данных. В ООП-стиле это делается через Visitor, в декларации которого приходится перечислять все K вариантов (а во всех Command приходится делать accept для Visitor-а).

              В функциональных языках с sum-типами и паттерн-матчингом эта же задача решается без каких-либо Visitor-ов.


              1. vadimr
                15.07.2025 13:05

                Методы обработки образуют список.

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

                Сама “задача применения M методов обработки к K вариантам данных” – это чисто функциональная постановка.


                1. eao197
                  15.07.2025 13:05

                  Сама “задача применения M методов обработки к K вариантам данных” – это чисто функциональная постановка.

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

                  Статья демонстрирует типичный ООП-шный подход, что, собственно, так и декларировалось. Зачем доколупываться с претензией на то, что "описали чисто функциональные конструкции" -- вот ХЗ.


                  1. vadimr
                    15.07.2025 13:05

                    > Сама “задача применения M методов обработки к K вариантам данных” – это чисто функциональная постановка.

                    Это, вообще-то говоря, просто общая постановка задачи.

                    Вроде, идеология ООП нас учит, что методы должны быть привязаны к данным?

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

                    По-моему, доколупываетесь с претензией здесь только Вы. А я написал, что автор очень грамотно провёл функциональный синтез, потом был вынужден реализовать свои идеи не очень подходящими для них средствами С++ и зачем-то назвал это использованием паттернов ООП. Хотя сами используемые паттерны ООП являются эмуляцией ФП.


                    1. eao197
                      15.07.2025 13:05

                      Вроде, идеология ООП нас учит, что методы должны быть привязаны к данным?

                      ООП учит, что данные конкретного ReadCommand не могут быть выставлены наружу в нарушение инкапсуляции. ООП не препятствует тому, чтобы пользователь знал, что за Command может скрываться ReadCommand, CloseCommand или еще что-то.

                      Хотя сами используемые паттерны ООП являются эмуляцией ФП.

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


                      1. vadimr
                        15.07.2025 13:05

                        К каким данным с точки зрения ООП привязан используемый автором метод modify, и что это за класс Modifier, в котором он находится? Не с точки зрения формального следования синтаксису C++, и не с точки зрения шаблона decorator (который представляет собой объектную адаптацию функции высшего порядка), а в абстрактной модели ООП? В которой о шаблонах не говорится ни слова.


                      1. eao197
                        15.07.2025 13:05

                        а в абстрактной модели ООП?

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

                        Если вы хотите кого-то убедить в том, что здесь именно функциональное программирование, а не ООП, то флаг вам в руки. К содержимому статьи это не имеет отношения. А вот ваше желание кого-то именно в этом убедить и есть то самое доколупывание, о котором я и сказал.


                      1. vadimr
                        15.07.2025 13:05

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

                        Ну вроде как ООП определяется именно как использование объектов, то есть структур, содержащих данные и работающие с ними функции?


                      1. eao197
                        15.07.2025 13:05

                        Почитали бы Бертрана Мейера, его книгу про объектно-ориентированное конструирование программ.


                      1. vadimr
                        15.07.2025 13:05

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

                        Может быть, есть какой-то аргумент по существу?


                      1. eao197
                        15.07.2025 13:05

                        Вот точно, именно апелляции к авторитету

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


                      1. vadimr
                        15.07.2025 13:05

                        Но возражает-то мне не Мейер, а вы. С Мейером, думаю, у меня бы разногласий по данному вопросу не возникло.

                        Мейер, кстати, не поощрял проектирование снизу-вверх.


                      1. eao197
                        15.07.2025 13:05

                        Но возражает-то

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

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


                      1. vadimr
                        15.07.2025 13:05

                        Любезнейший, автор вчера написал своё мнение в статье, я написал своё. Вроде бы как мы с ним друг друга поняли и никаких претензий друг к другу не имеем. Вдруг сегодня врываетесь вы и начинаете строчить свои комментарии, обвиняя меня в том, что якобы я к кому-то "доколупываюсь". Это просто фактологически неверно. Успокойтесь.

                        А чтобы кого-то "отправлять" надо, как минимум, лучше разбираться в вопросе, чем отправляемый, чего вы пока, увы, не продемонстрировали, в отличие от нескольких манипулятивных приёмов ведения диалога.


                      1. eao197
                        15.07.2025 13:05

                        Любезнейший, автор вчера написал своё мнение в статье, я написал своё.

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


                      1. vadimr
                        15.07.2025 13:05

                        А не надо писать неправду. Вы написали буквально следующее:

                        Зато связка из Command и Visitor-а там вот прям типичное ООП, да еще в варианте со статической типизаций. Или вы скажете что подобные вещи в функциональном подходе аналогичным образом решаются?

                        то есть прямой обращённый ко мне вопрос.

                        А когда я вам ответил, то, не в силах возразить по существу на мои аргументы, принялись за несколько наивные манипуляции.


                      1. eao197
                        15.07.2025 13:05

                        А не надо писать неправду.

                        Неправда в чем состоит?

                        Вот есть мои слова: "Зато связка из Command и Visitor-а там вот прям типичное ООП, да еще в варианте со статической типизаций."

                        Вы хотите сказать, что это не мое мнение, а значит неправда?

                        Или вот другие мои слова: "Если вы хотите кого-то убедить в том, что здесь именно функциональное программирование, а не ООП, то флаг вам в руки. К содержимому статьи это не имеет отношения. А вот ваше желание кого-то именно в этом убедить и есть то самое доколупывание, о котором я и сказал."

                        Вы хотите сказать, что это опять не мое мнение, а значит неправда?

                        O_o


  1. 9241304
    15.07.2025 13:05

    Ну раз плюсы, то обязательно надо разгуляться на все. Если нету шаблонов, абстрактных классов, и обязательно shared_from_this, посоны не поймут, засмеют. Хорошо, что хоть без sfinae и std::apply. Мож я чего не уловил, но проект не выглядит настолько сложным, чтобы все это применять


    1. eao197
      15.07.2025 13:05

      Ну раз плюсы, то обязательно надо разгуляться на все.

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

      Из тонкостей разве что в глаза отстутвие виртуального деструктора в Command в глаза бросается. Вот это наверняка не все считают.


      1. 9241304
        15.07.2025 13:05

        Возбудились - это скорее вы. У меня уже давно другая реакция на подобный оверинжиниринг.

        Не очень понимаю, откуда это постоянное желание задействовать ВСЕ возможности, в том числе виртуализировать всё на свете. Видимо, показная трушность перешла на новый уровень. )

        Особенно смешно получается, когда просто потом выкидываешь эти лохмотья - и всё начинает работать как минимум не хуже


        1. eao197
          15.07.2025 13:05

          Не очень понимаю, откуда это постоянное желание задействовать ВСЕ возможности

          Здесь нет, вероятно, и 10-й части возможностей C++.

          виртуализировать всё на свете

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

          Особенно смешно получается, когда просто потом выкидываешь эти лохмотья - и всё начинает работать как минимум не хуже

          Ваш код где-нибудь в открытом доступе посмотреть можно?


  1. eao197
    15.07.2025 13:05

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

    class ReadCommand : public Command {
        sptr<Command> accept(Visitor& visitor) override {
            return visitor.visit(get_shared_from_this()));
        }
    };
    

    Но практически такого же эффекта можно было бы достичь и в рамках предыдущих стандартов C++ без использования deducing this, просто определив вспомогательный метод в базовом классе Command:

    class Command
    {
    ...
    protected:
        template<typename T>
        sptr<Command> doAccept(T * self, Visitor & visitor) {
            return visitor.visit(
                std::static_pointer_cast<T>(shared_from_this()));
        }
    };
    
    class ReadCommand : public Command {
        sptr<Command> accept(Visitor& visitor) override {
            return doAccept(this, visitor);
        }
    };
    

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

    class ReadDelayVisitor : public Visitor {
    ...
    public:
        sptr<Command> visit(sptr<Command> cmd) { return cmd; }
        sptr<Command> visit(sptr<ReadCommand> cmd) {
            return make_sptr<DelayedReadCommand>(std::move(cmd));
        }
        sptr<Command> visit(sptr<WriteCommand> cmd) { return cmd; }
    };
    

    причем выглядит так, что большинство таких visit-ов будут однотипными, т.е. return cmd. И когда вы добавляете новую команду, то приходится добавлять новый visit в каждый Visitor. Пробовали ли вы как-то решить эту проблему? Или сочли, что это и не проблема вовсе?


    1. kkryukov2013 Автор
      15.07.2025 13:05

      Добрый день! Спасибо за столь подробный разбор :)
      Действительно, методы accept в случае deducing this не уйдут - мы избавимся лишь от необходимости копипастить имя класса при касте shared_from_this к правильному типу. В этом смысле с точки зрения избегания копипасты миксина будет даже лучше.

      Что касается дублирования visit для типов команд.
      Как я обозначил в начале статьи, у нас в проекте есть базовый и протокол-зависимый слой. На протокол-зависимом слое для модификации используются просто проверка кода операции, поскольку было бы безумием для каждого типа протокольной команды (в SCSI больше 50 команд, в мейерсане поддержаны на данный момент 16) заводить отдельный класс и на него писать ручку в визитёре. То есть условные операторы используются, т.к. модификаторы влияют точечно на протокольные команды, а не на группы команд.

      В свою очередь модификаторы базового уровня обобщают команды достаточно хорошо. Если поговорить немного о реальном положении дел, то на базовом уровне прямо сейчас в мейерсане существуют Command и IOCommand: последний содержит дескриптор операции, указывающий на природу IO. Т.е. в реальном коде сейчас поддержан гибридный подход на базовом уровне, в результате дублирования метода visit почти нет.


  1. domix32
    15.07.2025 13:05

    Надеялся что про 23 стандарт будет чуть пообширнее, а тут только deducing this


    1. kkryukov2013 Автор
      15.07.2025 13:05

      Добрый день :)
      В числе прочих сейчас в мейерсане используются библиотечные std::unreachable, std::expected, std::print, несколько новых views и другие фичи нового стандарта.
      В целом, в проекте нашли место в основном библиотечные функции, которые можно было бы показать в статье, но я посчитал, что они не будут так интересны читателям, поскольку в основном они могут быть получены путём использования сторонних библиотек.
      Однако стоит заметить, что очень приятно программировать, когда можно написать просто #include <expected> без предварительной линковки со сторонней библиотекой.