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

Какую проблему решаем
Системы хранения данных объединяют до 600 дисков в единое хранилище, предоставляют большой объем для хранения данных и обеспечивают высокую скорость доступа к ним. При этом диски в СХД — «расходный материал». Через несколько лет эксплуатации диск может начать вести себя не так, как сразу после завода. Мы в команде называем такие диски «плохими».
Чтобы пользователь не потерял доступ к данным, а система работала стабильно, СХД должна обнаруживать такие диски, помечать их как проблемные и подменять на исправные. СХД замечает «плохие» диски, когда запросы на запись или чтение к конкретному диску возвращаются дольше обычного. К тому же диск может портить данные и метаданные, выдавать ошибки.
Когда СХД понимает, что в одном из дисков возникла проблема, она должна переключить нагрузку на другой диск или снизить нагрузку на проблемный.
И тут возникает несколько вопросов:
По каким критериям определять, что диск испорчен.
Как написать систему, которая сможет находить неисправные диски.
И как тестировать такую систему.
Первое, что приходит в голову, — вставить в СХД сломанный диск и проверить, как реагирует система. Но у подхода есть большой минус: это ручное тестирование. Проводить его непрерывно и системно практически невозможно. Чтобы автоматизировать проверку дисков, мы разработали проект MeyerSAN.
Что такое MeyerSAN
Проект MeyerSAN — это программно-аппаратный комплекс на основе сервера VEGMAN. Он необходим для тестирования и валидации работы подсистем, которые находятся в составе СХД и определяют проблемные диски.
Как это работает:
Мы подключаем сервер к системе хранения данных.
С помощью ПО и драйверов заставляем СХД видеть сервер как диск или несколько дисков.
Вносим отклонения в поведение диска. Например, имитируем ситуацию, когда пользователь записал данные в диск, а прочитать их не смог.
Задача MeyerSAN — эмулировать проблемы с дисками: задержки, ошибки, порчу данных и метаданных.
Архитектура MeyerSAN состоит из трех больших блоков:

REST, так называемый MRSNMGMT. Позволяет конфигурировать систему в соответствии с пожеланиями.
MRSNLib. Cодержит бизнес-логику приложения: обработку и модификацию команд.
Драйверы низкого уровня. Они предоставляют нам механизмы транспорта и позволяют соответствовать всем протоколам.
В статье я сфокусируюсь на 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 на абстракции и виртуализацию. Однако отсутствие правильных абстракций приводит к трудностям в поддержке проекта. Поэтому ООП — это хорошо, если согласовать его с требованиями к производительности. Если можете лишний раз пожертвовать перформансом, это отличный повод ввести удобные абстракции.
Если вы правильно используете паттерны объектно-ориентированного программирования, повышается гибкость вашего проекта. А еще его удобнее поддерживать и сопровождать в будущем.
При использовании паттернов нужно уделить внимание перформансу. Когда вы начинаете думать об архитектуре проекта, вы всегда должны начинать с требований к производительности. Подумайте, можете ли вы себе позволить увесистую архитектуру или стоит вообще отказаться от каких-то абстракций и писать все на циклах и других простых конструкциях без абстрагирования.
«Заложите фундамент» там, где ваш код может расширяться. Например, вместо конструирования объекта напрямую воспользуйтесь фабрикой, а если у объектов многослойная структура, подумайте об использовании декоратора.
Не жалейте время на архитектурные сессии. Если фаза активной разработки немного растянется по времени, но работа в будущем станет проще и быстрее, бизнесу будет только лучше.
Нет универсального решения или инструмента. Реализация и архитектура проекта зависит от предметной области. Строить ПО нужно на основе требований, в том числе к производительности и поддержке. Требования бывают противоречивыми, поэтому важно уметь балансировать между ними и искать оптимальные подходы.
Как относитесь к ООП в проектах на С++? Напишите в комментариях.
vadimr
Вы приводите свою систему как пример ООП, но в действительности описали чисто функциональные по своей семантике конструкции - функции высшего порядка - реализованные через синтаксис классов C++ с использованием фактически функционального проектирования. Не то, чтобы от названия что-то принципиально зависело, но просто отмечаю для порядка. А в целом хорошая работа.
Честно говоря, с таким образом мысли, как у вас в этой статье, бросайте вы уже этот C++ и переходите на какой-нибудь функциональный язык :)
KurtkaBeyn
Верно, ещё и вечное упоминание, что надо писать "правильно", использовать "правильное" ООП, "правильную" архитектуру, очень глаз мозолит. Автор претендует на знание истины в последней инстанции
kkryukov2013 Автор
про "правильность" - как я обозначил в конце статьи, "нет универсального решения или инструмента".
Моя мысль не в том, что строить ОО архитектуру - это единственно правильный подход
Цель статьи - показать, как с помощью ОО подхода мы решили задачу, при этом удовлетворив как функциональным, так и нефункциональным требованиям.
Тем не менее, слово, возможно, действительно не самое удачное :)
kkryukov2013 Автор
Добрый день!
Я позволил себе термин "объектно-ориентированное проектирование", поскольку проект действительно состоит из объектов и классов, каждый из которых несёт единственную ответственность за опредённую функциональность: например, чтение данных с диска или модификацию выполненной команды.
Поэтому думаю, что в данном случае термин оправдан :)