В этом году на C++ Russia я рассказывал про API дизайн. Эта статья — пересказ и переосмысление моего доклада.

То, что я здесь расскажу, основано на моем личном опыте — про API дизайн я думаю уже лет 15, с того момента как в 2008м начал читать ревью библиотек на входе в boost (кстати, всем рекомендую).

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

Программная Архитектура

Сначала поговорим об архитектуре. В современных инженерных кругах каждый раз когда кто-то говорит слово "Архитектура," многие сразу думают о System Design собеседованиях в крупные зарубежные компании — как из кафки, редиса и PostgreSQL собрать сервис, который будет json'ы перекладывать.

Мы сегодня будем говорить об архитектуре на более низком уровне — об архитектуре на уровне отдельной программы или сервиса.

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

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

Если вы оказались в такой ситуации — вам пора задуматься об архитектуре вашего ПО.

Архитектура — это вид с высоты птичьего полета. Например, если вы пишете графический редактор, то вам наверняка придется написать код сохранения и загрузки разных форматов изображений, код инструментов, которые будут доступны пользователю, реализацию undo/redo, код различных фильтров, и т.п. Архитектура — это про то, как весь этот код связать в единое целое так, чтобы получилось достаточно гибкое решение.

Чем архитектура, о которой вовремя подумали, отличается от архитектуры, которая сама как-то зародилась, как жизнь в кастрюле с макаронами которую вы забыли на балконе? Что вообще такое "хорошая архитектура?" Мне нравится тейк автора Game Programming Patterns:

What is good software architecture?

For me, good design means that when I make a change, it’s as if the entire program was crafted in anticipation of it. I can solve a task with just a few choice function calls that slot in perfectly, leaving not the slightest ripple on the placid surface of the code.

That sounds pretty, but it’s not exactly actionable. “Just write your code so that changes don’t disturb its placid surface.” Right.

Let me break that down a bit. The first key piece is that architecture is about change. Someone has to be modifying the codebase. If no one is touching the code — whether because it’s perfect and complete or so wretched no one will sully their text editor with it — its design is irrelevant. The measure of a design is how easily it accommodates changes. With no changes, it’s a runner who never leaves the starting line.

За мыслью о легкости изменений на самом деле лежит довольно много:

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

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

  • Хорошая архитектура помогает не сажать баги.

  • Если вы все-таки посадили баг, то хорошая архитектура помогает быстро его найти.

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

  • Хорошая архитектура помогает быстрее решать возникающие бизнес-задачи, реализовывать новые фичи, ускорять тормозные участки.

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

На мой взгляд, об архитектуре нужно задумываться если:

  • Вы пишете код, который будет долго жить и широко использоваться.

  • Вы пишете библиотечный код.

  • Вы любите неконтролируемо архитектурить и хотите научиться делать это контролируемо.

Вопрос инвестирования усилий разработчиков в хорошую архитектуру — это на самом деле вопрос того, вернутся ли эти инвестиции. Оценить это без большого опыта в разработке бывает сложно, особенно если обе рассматриваемых альтернативы (инвестировать vs не инвестировать) покрыты для вас мраком, и конкретные шаги вам не ясны. Решить не инвестировать время в архитектуру потому что в данный момент этого не требуется, и не инвестировать в архитектуру потому что вы не понимаете, как и зачем — это две очень разные ситуации, которые при этом для стороннего наблюдателя могут выглядеть абсолютно одинаково. Вот только в первой ситуации решение приняли вы, а во второй ситуации решение случилось само. В этой статье я постараюсь частично развеять мрак, чтобы вы лучше понимали, какие шаги нужно предпринять, чтобы сделать ваш код лучше, и чаще оказывались в ситуации, когда вы можете принять осведомленное решение — архитектурить, или и так сойдет.

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

Что такое API Design?

Понятие API дизайна на самом деле тесно переплетено с понятием программной архитектуры.

И если архитектура в целом — это вид с высоты птичьего полета, то API дизайн — это вид вблизи, это то, что происходит между отдельными компонентами системы. Как связать А и Б, как написать код так чтобы потом не ловить баги, и т.п.

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

Все свойства хорошей архитектуры напрямую транслируются в API дизайн.

Зачем нужны хорошие API?

Вот две интересные ссылки:

По этим ссылкам один участник комитета по стандартизации C и С++ очень качественно горит. У многих людей на словосочетание "комитет по стандартизации С++" аллергия — они сразу представляют себе сборище бородатых дедов, которые все время что-то там стандартизируют, и каждый раз получается какая-то какаха оторванная от жизни фигня. Так вот, автор статьи — сразу в двух лагерях, и честно решил конструктивно посмотреть на свои страдания — "если комитет делает фигню, то я сам стану членом комитета и научу их как надо!" В частности, он недавно протащил #embed в С.

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

Ooooof....
Ooooof....

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

Как и в корне многих других проблем в национальной базе уязвимостей. Зачастую все начинается с примерно такого кода:

char buf[1024];

И заканчивается вот таким кодом:

// Checking string size is for retards!
strcpy(buf, totally_safe_string);

strcpy — это типичный пример сломанного API, который уже не починить, можно только выкинуть. Функции больше 40 лет, ей продолжают пользоваться, и она продолжает стрелять.

Но я уже слышу ваши возражения. API в недрах стандартной библиотеки? Кривой strcpy? Я json'ы перекладываю, и вообще не помню когда в последний раз писал вызов strcpy руками, какое отношение это все имеет лично ко мне?

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

Правило API дизайна #1

Проектируйте API так, чтобы его нельзя было использовать неправильно

Идея этого правила очень простая — ваш код должен или работать корректно, или завершаться с ошибкой (или assert'ом), и не должно существовать последовательности вызовов, которые приводят ваш класс (или модуль) в некорректное состояние.

Более того, чем меньше у вашего API способов завершиться с ошибкой — тем лучше! Зачем думать об ошибках и обрабатывать их, если можно спроектировать API, в котором их просто не будет?

Давайте посмотрим на несколько примеров.

Пример #1: Логирование

Например, предположим что вы пишете обертку над библиотекой для логирования:

class Logger {
public:
    /**
     * @param category      Log category name. Category must have a log level 
     *                      assigned (`setCategoryLogLevel` must be called 
     *                      first).
     * @param level         Message log level.
     * @param fmt, args     Log message.
     */
    template<class... Args>
    void log(std::string_view category, LogLevel level, 
             fmt::format_string<Args...> fmt, Args &&... args);

    /**
     * @param category      Log category name.
     * @param level         New log level for this category. Log messages with 
     *                      lower log level will be ignored.
     */
    void setCategoryLogLevel(std::string_view category, LogLevel level);
};

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

class LogCategory {
public:
    LogCategory(std::string_view name, LogLevel level);

    void setLogLevel(LogLevel level);

    // ...
};

class Logger {
public:
    template<class... Args>
    void log(const LogCategory &category, LogLevel level, 
             fmt::format_string<Args...> fmt, Args &&... args);

    // Don't do `log(LogCategory("blabla", LOG_INFO), ...)`, 
    // create a variable for your `LogCategory` and reuse it.
    template<class... Args>
    void log(LogCategory &&, Args &&...) = delete;
};

При таком API у вас сразу пропадает проблема того, что пользователь может опечататься в названии категории — опечатываться-то теперь негде! Только в названии переменной типа LogCategory, а это поймает компилятор.

Пример #2: Менеджер CSV файлов

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

struct CsvStats {
    DateTime startTime; // First timestamp in CSV table.
    DateTime endTime;   // Last timestamp in CSV table.
    // ...
};

class CsvDb {
public:
    explicit CsvDb(std::string_view path);

    std::string path(std::string_view tableName);

    CsvStats stats(std::string_view tableName);
    void setStats(std::string_view tableName, CsvStats stats);
};

???? Вы наверняка хотите спросить, зачем все это. Можно сложить данные в базу данных. Можно жить как человек и пользоваться распространенными в индустрии решениями — поднять Hadoop и Spark, например. Предположим, что в данном примере вам нужно максимально легковесное решение.

У CsvDb очень простой интерфейс — есть возможность получить полный путь до таблицы по имени, и есть getter и setter для статистик. Статистики сохраняются на диске в каком-нибудь db.ini и flush'атся после каждого вызова setStats. Ожидается, что пользоваться вашей оберткой будут примерно следующим образом:

  1. Получат статистику по имени таблицы.

  2. Если в таблице не хватает данных — докачают их.

  3. Получат путь до файла с таблицей вызвав path, подмержат туда скачанные данные.

  4. Вызовут setStats, чтобы обновить статистики на новые.

  5. Используют обновленную таблицу.

Что здесь может пойти не так? Например то, что подсчет статистик — целиком на пользователе, и что подмерживание таблицы и обновление статистик — это не атомарная операция.

Как можно сделать то же самое лучше:

struct CsvStats {
    DateTime startTime;
    DateTime endTime;
    // ...
};

class CsvDb {
public:
    explicit CsvDb(std::filesystem::path path);

    CsvTableReader open(std::string_view tableName);
    CsvTableWriter replace(std::string_view tableName);

    CsvStats stats(std::string_view tableName);
};

// Skipping CsvTableReader & CsvTableWriter for brevity.

Что изменилось:

  • Теперь пользователь нашего интерфейса вообще ничего не знает о файлах.

  • Статистики рассчитываются и сохраняются внутри CsvTableWriter. Пользователю не нужно делать это самому.

  • Интерфейс стал лучше отражать суть происходящего — open и replace это по сути getter и setter для отдельной таблицы, и setter теперь можно реализовать так, чтобы таблица и ее статистика обновлялись атомарно. Предыдущая версия API концентрировалась на getter'ах и setter'ах для статистик

Правда, теперь у нас 4 класса вместо двух. Это должно навести вас на мысль!

Правило API дизайна #2

Divide & Conquer

Вы наверняка знакомы с акронимом SOLID. Я, честно признаться, никогда не мог запомнить, что означают все эти буквы, но две из них я помню очень четко. Даже если вы разбудите меня посреди ночи, то я уверенно вам скажу, что S — это про single responsibility, а L — это про Liskov substitution.

За S в SOLID на самом деле сидит довольно глубокая мысль. В оригинале правило звучит как "there should never be more than one reason for a class to change," и на деле это означает, что зоны ответственности классов должны быть очень небольшими. А значит и сами классы тоже должны быть небольшими.

Почему небольшие классы — это хорошо? Давайте подумаем о том, как вы на самом деле пишете код:

  1. Сначала вам нужно осознать задачу — какую функциональность вы вообще хотите реализовать.

  2. Затем вам нужно осознать контекст — буквально, загрузить в голову весь код, который вам нужно будет менять, и от которого так или иначе будет зависеть новая функциональность.

  3. После этого вам нужно придумать решение — как вы поменяете код, который уже у вас в голове.

  4. Постучать пальцами по клавиатуре.

Зачастую, вся сложность оказывается вовсе не в шаге 4. Если вы работаете с большими классами, то они не влезают в вашу оперативную память. И вы или страдаете и тратите больше времени, чем могли бы, или сажаете баги.

Более того, внутри класса нет границ, все поля доступны из любого метода, и количество возможных взаимодействий между частями класса растет как квадрат от размера класса. А вместе с количеством взаимодействий растет и количество багов.

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

Аналогично работает и с библиотеками. Классы в "помойке классов" начинают зависеть друг от друга, снова квадрат зависимостей и баги. Дробите ваш код на мелкие библиотеки! И такая же логика применима к функциям — не просто так все ругают функции на 10 экранов.

Пример #3: Парсинг строковых данных

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

/**
 * @param buffer            Input buffer to parse, contains null-terminated 
 *                          strings.
 * @param[out] result       Parsed strings.
 */
void parseStrings(const Buffer &buffer, std::vector<std::string> *result) {
    size_t pos = 0;                                                        
    while (pos < buffer.size()) {
        // Extract the next string.
        const char *nextPos = static_cast<const char *>(memchr(buffer.data() + pos, '\0', buffer.size() - pos));
        size_t size = (nextPos ? nextPos - buffer.data() : buffer.size()) - pos;
        std::string str = std::string(buffer.data() + pos, size);

        // Remove quotes if the string is quoted.
        if (str.size() >= 2 && str.front() == '"' && str.back() == '"')
            str = str.substr(1, str.size() - 2);

        // Store result & advance.
        result->push_back(std::move(str));
        pos += size + 1;
    }
}

Вы подобный код наверняка видели много раз. Что с ним не так? Например то, что неподготовленному человеку вообще не ясно, что происходит в первых трех строках внутри цикла. Просто попробуйте вчитаться — вот например, вы вообще помните, что возвращает memchr?

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

void parseStrings(const Buffer &buffer, std::vector<std::string> *result) {
    MemoryInput input(buffer);

    std::string line;
    while (input.readLine(&line, '\0'))
        result->push_back(unquote(line));
}

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

  1. Класса MemoryInput, который отвечает за потоковое чтение из участка памяти.

  2. Функции unquote, которая удаляет кавычки.

И класс, и новую функцию теперь можно покрыть тестами и переиспользовать в других местах.

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

В целом, это очень хороший подход к дизайну API. Если вы видите сложный код:

  1. Подумайте, каких абстракций вам не хватает.

  2. Выделите их в отдельные сущности, и разделите свой код на слои с их использованием.

???? Если пойти чуть дальше, то становится ясно, что этого кода вообще быть не должно. Если у вас null-терминированные строки лежат в файле, то просто замапьте его в память и сделайте массив из std::string_view, которые в эту память смотрят. А еще лучше — если у вас не сотни тысяч строк, то сложите их просто в какой-нибудь human-readable формат, для которого умные люди уже написали парсинг. Json, toml, ini, yaml, csv, prototext, и т.п.

Пример #4: QFuture

Давайте теперь посмотрим на жизненный пример из библиотеки Qt — класс QFuture. Это по сути прокачанный аналог std::future, абстракция над асинхронным вычислением. Интерфейс выглядит примерно так:

template<class T>
class QFuture {
public:
    QFuture(const QFuture &other);

    const_iterator begin() const;
    const_iterator end() const;
    QList<T> results() const;

    void cancel();
    T takeResult();
    T result() const;

    template<class Function>
    auto then(Function &&function);
    template<class Function>
    auto then(QThreadPool *pool, Function &&function);
    template<class Function>
    auto then(QObject *context, Function &&function);

    bool isCanceled() const;
    bool isFinished() const;
    bool isRunning() const;
    bool isStarted() const;
    bool isValid() const;

    // ...
};

Какие у этого интерфейса есть проблемы:

  • QFuture это ref-counted класс, то есть по сути QFuture это аналог std::shared_future.

  • С другой стороны у QFuture есть метод takeResult, который явно не вписывается в концепцию std::shared_future.

  • А begin / end — это вообще история про канал / асинхронную последовательность.

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

  • Метод then принимающий QObject * в качестве контекста работает не так, как опытный пользователь Qt может ожидать — в отличие от QObject::connect, QFuture::then не следит за временем жизни переданного объекта, и у вас все упадет если переданный объект будет уничтожен до вызова континуации.

  • Огромная куча методов isXYZ дают доступ к текущему состоянию объекта, но даже из документации не ясно, какие состояния являются взаимоисключающими, а какие — нет. Если состояния являются взаимоисключающими, то лучше предоставлять единый метод state().

В общем, QFuture пытается быть всем и сразу. У этого желания есть большой минус, и вы увидите его если попробуете почитать исходники — в QFutureInterfaceBasePrivate целых 20 полей, и уследить за происходящим там очень сложно.

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

Правило API дизайна #3

Тратьте время на придумывание хороших имен!

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

Перед именованием потратьте время и сформулируйте, в чем вообще концептуальная суть абстракции, которую вам нужно назвать. Не поддавайтесь на соблазны — никто не будет рад очередному классу, заканчивающемуся на Helper. Если все равно не получается — воспользуйтесь thesaurus.com или ChatGPT. Если и после этого получается фигня — значит вы придумали кривую абстракцию, думайте еще.

Продолжаем пример #4: QFuture

В случае QFuture из примера выше, если вы зададите себе правильные вопросы, то будет понятно, что за QFuture спрятались сразу несколько абстракций, которые стоит разделить:

template<class T>
class QAsyncSequence {
public:
    void cancel();
    const_iterator begin() const;
    const_iterator end() const;
    QList<T> results() const;
    // ...
};

template<class T>
class QUniqueFuture {
public:
    QUniqueFuture(QUniqueFuture &&other); 
    void cancel();
    T takeResult();
    // ...
};

template<class T>
class QSharedFuture {
public:
    QSharedFuture(const QSharedFuture &other); 
    QSharedFuture(QUniqueFuture<t> &&other);
    void cancel();
    T result() const;
    // ...
};

QAsyncSequence — это абстракция над асинхронной последовательностью, а QSharedFuture и QUniqueFuture — аналоги std::shared_future и std::future. У изначального QFuture существовали некорректные сценарии использования, которые приводили к ошибке во время выполнения — например, можно было скопировать QFuture, для двух копий вызвать takeResult, и упасть с assert'ом. С новыми интерфейсами такой код просто не получится написать!

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

Но вернемся к QFuture. Выше я обратил ваше внимание на то, что метод QFuture::then с одним аргументом фундаментально сломан — у вас нет способа гарантированно исполнить континуацию в контексте выполнения операции, доступ к результату которой предоставляет QFuture. Если асинхронная операция завершится до вызова then, то континуация будет вызвана прямо изнутри then, и это никак нельзя обойти.

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

Внимательный читатель может также заметить, что можно просто использовать boost::future, у которого таких проблем почти нет. Но дело в том, что почти нас не устраивает — метод then c одним аргументом все еще сломан, и переданная континуация все еще может быть исполнена в месте вызова, просто в отличие от QFuture это полностью детерминировано и определяется тем, с каким boost::launch был создан этот boost::future.

Эти проблемы должны, в очередной раз, заставить вас задуматься. На мой взгляд, QFuture и std::future — это просто плохие абстракции. Они объединяют в одном интерфейсе асинхронную операцию и контекст, в рамках которого эта операция выполняется, и это приводит к проблемам. Можно ли сделать лучше?

Пример #5: Лучше чем QFuture

Хорошая практика в дизайне API — начинать с клиентского кода. Если вы не знаете, какой API будет более удобен — сначала реализуйте несколько примеров использования, не имея готовой реализации самого API. Это поможет вам понять, какой вариант лучше подходит под ваши требования, а написанный код потом можно будет использовать для тестирования.

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

/**
 * @param network           Network access object.
 * @param opts              Fetch options - login, number of posts, etc.
 * @return                  Async fetch task. 
 */
Task<TwitterPosts> fetchTwitterPosts(Network &network, 
                                     const TwitterFetchOptions &opts) {
    Task<std::string> requestTask = network.request(makeRequestUrl(opts));

    return requestTask.then([](std::string_view jsonData) {
        TwitterPosts result;
        deserialize(Json::parse(jsonData), &result);
        return result;
    });
}

void myAwesomeFunction(Network &network) {
    // Download the latest 20 posts by Bjarne Stroustrup & print them.
    TwitterFetchOptions opts("@stroustrup", 20);
    TwitterPosts posts = fetchTwitterPosts(network, opts)
        .run(globalThreadPool())
        .join();
    fmt::println("{}", posts);
}

Здесь Network::request возвращает объект типа Task — абстракцию над асинхронной операцией, не привязанной к контексту выполнения. Поддержка континуаций реализована через метод Task::then, который тоже возвращает Task. Функция fetchTwitterPosts ничего не знает про контекст выполнения — она содержит только логику операции, контекст же передается в метод Task::run внутри myAwesomeFunction — в данном случае запрос выполняется в глобальном тредпуле.

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

Ортогональность — это одно из ключевых свойств, которыми обладают абстракции в хорошо задизайненных библиотеках.

???? Если вы пользовались boost::asio или Qt, то код выше может вызвать у вас вопросы — например, где здесь event loop? Да и асинхронные операции бывают разные — можно ждать пока байты по сети приедут, а можно считать md5 от файла на 10 гигабайт, и для первого примера отдельный поток вообще может быть не нужен. На эти вопросы нет коротких ответов, если хотите глубже погрузиться в проблематику — рекомендую посмотреть доклад Eric Niebler про executors.

Правило API дизайна #4

Создавайте ортогональные и взаимозаменяемые абстракции

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

STL определяет две ключевых абстракции — итераторы и алгоритмы. И, что самое главное — в STL эти абстракции ортогональны, и это дает возможность, к примеру, использовать единую реализацию std::find_if для std::deque и для std::vector.

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

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

  • Overload sets, шаблоны и концепты для статического полиморфизма.

Немного про полиморфизм

Статический полиморфизм в C++ у большинства разработчиков ассоциируется с шаблонами, так что вы могли споткнуться на "overload sets" выше. Чтобы понять, о чем это вообще, нужно вспомнить что полиморфизм в С++ бывает двух видов:

  • Интрузивный — требующий прямой поддержки в коде класса.

  • Неинтрузивный — подразумевающий, что вы можете "подогнать" любой класс под требования, не меняя код класса.

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

Overload sets реализуют неинтрузивный статический полиморфизм, и опираются на то, что статический интерфейс класса — это не только методы класса, но и свободные функции доступные через argument dependent lookup. Классическими примерами являются operator<< для вывода в поток, и функция PrintTo из Google Test.

Интрузивный статический полиморфизм — это просто требования на методы класса. Такой полиморфизм обычно менее гибок.

Последнее комбо — это неинтрузивный динамический полиморфизм. Классический пример — QVariant из Qt, и идущий комплектом QMetaType. На самом примитивном уровне, неинтрузивный динамический полиморфизм — это std::unordered_map<std::type_index, void(*)(void *)>, маппинг из типа в какую-либо операцию над объектом этого типа.

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

Пример #6: Пишем Roguelike

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

Предположим, что вы много играли в ADoM, и решили написать свою roguelike ролевую игру. Классические roguelike RPG отличались отсутствием графики, огромным количеством контента (сотни типов монстров и снаряжения), и невероятной гибкостью — the DevTeam thinks of everything. Такое многообразие имеет свою цену — если вы не подумаете заранее о том, как спроектировать игровую модель, то в какой-то момент логика вашей игры будет намазана ровным слоем по всей вашей кодовой базе. Нужно реализовать про́клятый рунический меч, который иногда сам бьет ваших союзников и высасывает их души — легко, вот 10 мест в которые надо дописать немного кода. Знакомая ситуация?

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

class Equipment {
public:
    virtual ~Equipment() = default;
    virtual void onUse() = 0;

    // ...
};

Любой предмет можно использовать, так что у базового класса есть метод onUse. У этого метода наверное должны быть какие-то параметры, но мы их пока что опустим и подумаем о других типах предметов:

class Weapon : public Equipment {
public:
    virtual void onAttack(Monster &monster) = 0;

    // ...
};

class VampiricSword : public Weapon {
public:
    virtual void onAttack(Monster &monster) override {
        Damage damage(this, DMG_PHYSICAL, _dice.roll());
        monster.takeDamage(damage);

        if (damage.amount <= 1) 
            return;

        Damage healing(this, DMG_DARKMAGIC, damage.amount / 2);
        owner().heal(healing);
    }

    // ...

private:
    Dice _dice;
};

"Черт возьми, это выглядит замесно!" — думаете вы, и начинаете добавлять разные типы брони:

class Armor : public Equipment {
public:
    virtual void onTakeDamage(Damage &damage) = 0;

    // ...
};

Теперь нужно реализовать щит, но вот незадача — щитом-то тоже можно бить супостатов!

class Shield : public Armor, public Weapon { // Eeeeeh?
    // ...
};

И у вас проблема. Вы конечно же сразу вспоминаете, что в C++ есть виртуальное наследование, и оно даже в стандартной библиотеке используется! Сделать class Equipment виртуальным базовым классом — и проблема решена?

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

???? Да, это означает что стандартные потоки ввода-вывода задизайнены плохо — это правда, их принесли в C++ когда C++ был совсем другим языком (и назывался Cfront). Первая редакция The C++ Programming Language была опубликована в 1985м, так что вы можете прикинуть, сколько лет <iostream>. Сегодня мы гораздо лучше понимаем, что такое хороший дизайн, да и современный C++ очень сильно отличается от C++ из 80х. В стандартной библиотеке C++ хватает спорных решений (привет vector<bool>, здравствуй std::locale), и не всегда с нее нужно брать пример.

В случае игровой модели для roguelike RPG вы можете вдохновиться Entity Component System, и прийти к чему-то такому:

class Event {
public:
    explicit Event(EventType type) : type(type) {}
    virtual ~Event() = default;

    const EventType type;

    // ...
};

class Behaviour { // Behaviours are composable pieces of event-handling logic.
public:
    explicit Behaviour(Entity *owner): _owner(owner) {}
    virtual ~Behaviour() = default;

    virtual void process(Event *event) = 0;

    // ...

protected:
    Entity *owner() const {
        return _owner;
    }

private:
    Entity *const _owner = nullptr;
};

struct Entity {
    std::vector<std::unique_ptr<Behaviour>> behaviours;

    // ...
};

В таком подходе у вас есть события, которыми обмениваются объекты, и behaviours, которые могут эти события обрабатывать. Отдельные предметы — это экземпляры класса Entity, и важно, что для реализации различных предметов вам не нужно наследоваться от Entity, вся логика содержится в Entity::behaviours.

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

Теперь давайте определим базовые события, которые нам будут нужны для реализации VampiricSword:

/**
 * When performing an attack, this event is sent to the attacker's items to 
 * populate the damage rolls.
 */
class AttackOutEvent : public Event {
public:
    AttackOutEvent() : Event(EVENT_ATTACK_OUT) {}

    std::vector<Damage> damageRolls;

    // ...
};

/**
 * When performing an attack, this event is sent to the target's items to 
 * apply armor & protection.
 */
class AttackInEvent : public Event {
public:
    AttackInEvent() : Event(EVENT_ATTACK_IN) {}

    std::vector<Damage> damageRolls;

    // ...
};

/**
 * After a successful attack, this event is sent back to the attacker's items 
 * to notify of success / failure.
 */
class AttackNotifyEvent : public Event {
public:
    AttackNotifyEvent() : Event(EVENT_ATTACK_NOTIFY) {}

    std::vector<Damage> damageRolls;

    // ...    
};

Таким образом, атака будет обрабатываться следующим образом:

  • Предметы игрока обработают AttackOutEvent и заполнят массив damageRolls.

  • Предметы монстра обработают AttackInEvent и обновят массив damageRolls.

  • Урон будет нанесен монстру.

  • Предметы игрока получат нотификацию AttackNotifyEvent об успешной / неуспешной атаке.

Теперь реализуем VampiricSword:

class WeaponBehaviour : public Behaviour {
public:
    virtual void process(Event *event) override {
        if (event->type != EVENT_ATTACK_OUT)
            return;
        AttackOutEvent *e = static_cast<AttackOutEvent *>(event);

        e->damageRolls.push_back(Damage(owner(), DMG_PHYSICAL, _dice.roll()));
    }

    // ...

private:
    Dice _dice;
};

class LifeStealingBehaviour : public Behaviour {
public:
    virtual void process(Event *event) override {
        if (event->type != EVENT_ATTACK_NOTIFY)
            return;
        AttackNotifyEvent *e = static_cast<AttackNotifyEvent *>(event);

        int damageAmount = 0;
        for (const Damage &damage : e->damageRolls)
            if (damage.source == owner() && damage.type == DMG_PHYSICAL)
                damageAmount += damage.amount;

        if (damageAmount <= 1)
            return;

        // Get owning actor - monster or player. Monsters & player are 
        // entities too! 
        Entity *actor = actorOf(owner());

        // Owning actor can be null. E.g. this item is a sword lying on 
        // the ground, the room is dark, player is cursed, and his big 
        // toe connects with the pointy end.
        if (actor) {
            Damage healing(owner(), DMG_DARKMAGIC, damageAmount / 2);
            sendEvent(actor, SpellEvent(SPELL_VAMPIRIC_HEALING, healing));
        }
    }

    // ...
};

std::unique_ptr<Entity> makeVampiricSword(Dice damageDice) {
    auto result = std::make_unique<Entity>();
    result->behaviours.emplace_back(
        std::make_unique<WeaponBehaviour>(result.get(), damageDice));
    result->behaviours.emplace_back(
        std::make_unique<LifeStealingBehaviour>(result.get()));
    return result;
}

Прочитайте внимательно код — важно, что WeaponBehaviour и LifeStealingBehaviour ничего не знают друг о друге. Разные Behaviour'ы можно намешать как душе угодно. И теперь вы можете легко делать самые невероятные вещи:

  • Шипованный щит хаоса — это WeaponBehaviour плюс ArmorBehaviour плюс CorruptingBehaviour.

  • Меч огня — WeaponBehaviour плюс MagicDamageBehaviour(DMG_FIRE).

  • Кольцо льда — ResistanceBehaviour(DMG_ICE) плюс VulnerabilityBehaviour(DMG_FIRE).

Реализации всех этих Behaviour'ов я приводить не буду, в целом понятно что там должно происходить. Важно, что теперь:

  • Вы легко переиспользуете код.

  • Вы получаете невероятную гибкость — например, вы теперь легко можете реализовать меч, который днем — огненный, а ночью — ледяной. И вы даже переиспользуете MagicDamageBehaviour.

  • Код отдельных кусочков функциональности теперь локализован. У вас нет проблемы с тем, что код фичи размазан по 10 местам вашей кодовой базы.

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

  • Немного поприседав, вы теперь можете реализовать загрузку предметов из файла, чтобы ваш гейм-дизайнер мог писать что-то такое в items.json:

{
    "unidentified_name" : "long sword",
    "name" : "vampiric sword",
    "description" : "A long sword imbued with dark magic that heals its owner when it's dealing damage to living creatures. Its blade is eerily warm to the touch.",
    "behaviours": [
        {
            "type" : "weapon",
            "damageDice" : "3d5+5"
        },
        {
            "type" : "lifeStealing"
        }
    ]
}

И все! Теперь ваш гейм-дизайнер здоровается с вами за руку и угощает пивом. И вам не нужно писать код каждый раз когда ему в голову приходит очередная гениальная идея (что случается несколько раз в день) — он может собрать что ему нужно из кусочков сам!

Если вам интересно чуть глубже погрузиться в детали работы ECS — рекомендую доклад Brian Bucklew про Caves of Qud, доклад Bob Nystrom о дизайне Roguelike игр, и документацию ENTT.

Осмысляем пример #6

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

Поэтому мы посмотрели, что умные люди уже придумали до нас, и применили классический паттерн — chain of responsibility:

  • Event — это команда в терминах паттерна chain of responsibility, абстракция над действием или отдельным шагом какого-либо действия;

  • Behaviour — это обработчик команд, абстракция над отделимым кусочком игровой логики;

  • Entity — набор обработчиков, реализующий паттерн chain of responsibility, а также абстракция для игровых предметов.

И важно, что все эти абстракции ортогональныEvent'ы ничего не знают о конкретных Behaviour'ах, а Behaviour'ы не знают ничего друг о друге и об объемлющем Entity (кроме того, что последний существует). Введение дополнительных ортогональных абстракций одновременно позволило нам упорядочить игровую логику, и получить невероятную гибкость — то, что было намазано ровным слоем по виртуальным функциям, теперь спрятано за единым интерфейсом, и то, что раньше требовало написания дополнительного кода в нескольких местах, теперь может быть реализовано просто изменением игровых data-файлов.

В качестве приятного бонуса мы получили тестируемость всей нашей функциональности — ведь все Behaviour'ы отделимы, и могут быть легко протестированы в любых комбинациях.

???? Один из признаков того, что вы правильно попилили свои абстракции и пришли к хорошему API — это легкость, с которой вы пишете тесты. Если вам для того чтобы нормально протестировать свой код приходится втыкать if(currentlyTesting) в случайных местах, то с вашим API точно что-то не так. С другой стороны, писать тесты для хорошего API обычно легко и приятно — как будто код сидел и ждал, когда же его наконец-то покроют тестами, и основательно к этому подготовился, чтобы не дай бог не спугнуть случайно забредшего в папку tests разработчика.

???? На C++ Russia слушатели верно заметили, что дебажить новую конструкцию сложнее. Это правда, но это та цена, которую вы платите за гибкость, и от этого не убежать. Зачастую банальный вывод всех Event'ов в stderr может помочь вам понять, что идет не так. И благодаря тестируемости дизайна, после локализации бага вы всегда легко можете написать тест.

Подводя итоги

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

В большинстве случаев чтобы прийти к достаточно хорошему API вам будет достаточно перечисленных выше правил. Напомню:

  1. Проектируйте API так, чтобы его нельзя было использовать неправильно. Все возможные способы использования вашего API должны или отрабатывать корректно, или завершаться ошибкой!

  2. Divide & Conquer. Дробите ваш код — на классы, на модули, на функции, на слои абстракции.

  3. Тратьте время на придумывание хороших имен! Если не получается придумать нормальное имя — значит вы придумали плохую абстракцию.

  4. Создавайте ортогональные и взаимозаменяемые абстракции. Проверяйте качество абстракций по тестируемости вашего кода — правильно попиленный на абстракции API легко и приятно тестировать! Если не знаете, с чего начать — вспомните пример STL, "данные ортогональны логике."

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

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


  1. Raider
    04.08.2023 06:47

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


    1. perfect_genius
      04.08.2023 06:47

      Да и баги, обычно, "плодят" или "делают".


      1. Raider
        04.08.2023 06:47

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


  1. Raider
    04.08.2023 06:47

    Вы могли бы уточнить по коду. .run(globalThreadPool()) -- подразумевается что таким образом указывается, где должны выполняться продолжения (зарегистрированные раннее в then)?


    1. Edric Автор
      04.08.2023 06:47

      В run передается контекст выполнения, в котором будет выполняться Task целиком. Идея здесь в том, что пока run не вызван, ничего не происходит — запрос по сети не отправляется и т.п. Это означает, что в пользовательском коде вы сначала можете спокойно выстроить цепочку вычислений через then, и уже после этого отправить ее на выполнение — например, в глобальном пуле потоков.


      1. Raider
        04.08.2023 06:47

        Благодарю за пояснение! Действительно, напоминает модель stdexec/executors.


      1. boldape
        04.08.2023 06:47

        А что делать в с вашим АПИ если лэйзи инициализация по какой то причине не вариант? Ну или более конкретно нужно поднасыпать зенов когда лягушку уже варят или уже сварили? А ещё точнее ваш АПИ нарушает ваше 1 правило, таск ран я могу вызвать несколько раз, могу добавить зенов после ран. По вашей лэйзи логике ран должен быть у пула и консумить таск и возвращать какой то токен на котором можно только ждать или спросить о готовности.


        1. Edric Автор
          04.08.2023 06:47

          По сути run возвращает как раз такой токен, и консьюмит Task.

          На тему добавления then к этому токену — это хороший вопрос, но надо смотреть на конкретный юзкейс. Мне в проектах на Qt обычно хватало примерно такого API:

          request(url)
               .then(&parse)
               .notify(object, &Object::method)
               .run(globalThreadPool());

          Где notify() работает так же, как QObject::connect.


          1. boldape
            04.08.2023 06:47

            Т.е. повторный ран вернёт, токен который что? Комплетед как при кенселе? Или повторный ран кинет исключение? Это конечно вкусовщина, но все же явный мув для таска как то более идиоматичен. И я так подумал, и вижу что ваш токен без зена, по сути ни чем от стд футуры не отличается, я бы даже ее и возвращал, хотя возможно у вас там есть кэнсел и метод узнать про это. Вот вы писали статью про АПИ, а в этом примере ничего толком не показали т.к все декларации нужно самому додумывать, так не годится конечно.

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

            Вообщем, опять же вкусовщина, но я вижу ровно 0 принципиальных отличий вашего дизайна от пары стд асинк/футура (ну разве что у вас ещё кэнсел завезли). Другими словами если я могу лэйзи собрать таск, то что мне мешает также лэйзи собрать все лямбды в одну а потом ее родную и отправить в асинк получить футуру и также ждать на ней? Даже больше, согласно вашим же рекомендациям именно так и нужно сделать, т.к. это добавляет ещё больше ортогональности, комбинировать лямбды может понадобиться и в отдельности от асинхронщины. А кэнселейшен токен (если нужен) можно через параметр лямбды передавать и потом через тупл вместе с рещультатам всем последующим лямбдам в цепочке.

            Короче я про то что связывать чейнинг лямбд с асинхронщиной да ещё и мешать туда культю в одном флаконе как то противоречит вашим же заветам по АПИ. Но хочу выразить благодарность за примеры, и ссылку на ниблера, у меня просто эта боль есть и мне "скоро" это все рефакторить, теперь я вижу как это красиво сделать, и я тоже на культе сижу это 3 ортогональное измерение для АПИ, которое не нужно подмешивать в таски т.к. у нас не везде разрешено в АПИ ссылаться на кутэ, а в некоторых либах даже и использовать ее низя.


            1. Edric Автор
              04.08.2023 06:47

              Спасибо за комментарии!

              Про then для future-like объекта, возвращаемого из run — а там нет then, в этом-то и смысл. При большом желании можно туда приделать notify, который по завершению будет посылать Qt'шный сигнал с Qt::QueuedConnection, и проблемы как с QFuture там не будет.

              Про лямбды — на лямбды в этом примере Task не заменить потому что Network::request должен нетривиально взаимодействовать с event loop'ом. В общем, как я написал в статье — коротких ответов нет, если в этот пример глубоко погружаться и честно и качественно дизайнить, то это отдельная большая тема.

              Этот пример был про "давайте подумаем, как мог бы выглядеть клиентский код, чтобы было удобно," на идеальность предложенного API я конечно не претендую, да я и сам API не показал по сути X). За проработанном решением этой конкретной проблемы надо конечно идти к Eric Niebler — он над этим думает последние лет 5.


  1. clownblvde
    04.08.2023 06:47
    +3

    Первая статья, прочитанная мной на Хабре, мало что понял, но было интересно, особенно читать о том о чем знаю немного


  1. ruomserg
    04.08.2023 06:47
    +5

    Классная статья!


    Мой урок из написания API (и пользования чужими) — если вы делаете какое-то сложное поведение, то желательно предусмотреть несколько уровней использования.


    1. Самый нижний — hacker mode: доступ к элементарным функциям которые пользователь может скомпоновать так, как считает нужным (при условии что имеет достаточный опыт и понимание).
    2. Средний — reasonable defaults. Почти готовое к употреблению — пользователь создает контекст/меняет поведение только основных параметров. Для основной массы людей.
    3. Верхний — safe playground. Максимально простое использование даже в ущерб памяти/эффективности. Для знакомства с функционалом и быстрого прототипирования.

    Самое страшное — это когда авторы в целом хорошей библиотеки считают что все будут ее использовать ровно в той ситуации как они, и дают только третий или второй-третий уровни. А потом ты оказываешься в ситуации когда надо сделать какой-то нестандарт, и вроде вот она хорошая библиотека, а не лезет! И начинаются извращения с запихиванием круглого кода в квадратное отверстие — а то и пиление новой библиотки с нуля...


  1. SpiderEkb
    04.08.2023 06:47
    +5

    Бездумное увлечение абстракциями тоже не есть хорошо. Смотришь порой чужой код и хочется повеситься. Или застрелиться. Или все сразу.

    Внешне все красиво. Коротенькие функции, абстракции и все вот это вот. Но когда встает задача досконально разобраться не что это функция делает в принципе, а как именно она это делает в деталях, сталкиваешься тем, что вместо того, чтобы просто пролистать 2-3 экрана линейного кода, тебе приходится в открыть 5-10 окошек из разных модулей и очень внимательно смотреть, постоянно "перепрыгивая" из одного окна в другое и обратно, кто кого откуда вызывает, кто что кому возвращает и т.п. И порой проще забить на все это и тупо простепать под отладчиком. Ей богу, благое желание "улучшить читаемость кода" в итоге тут работает ровно наоборот.

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

    Лирическое отступление

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

    и вот эти лишние выкрутасы со стеком и динамической памятью тут ну совсем ни к чему.

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

    В целом же - согласе практически со всеми Вашими тезисами относительно правил построения API.


  1. anz
    04.08.2023 06:47
    +3

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

    STL в качестве примера хорошего API? Честно говоря по-моему это худших пример API, который можно найти. Отдельный std::find_if конечно удобен его разработчику, но не удобен пользователю. Если сравнить STL API с любым другим языком, например C#, то становится просто больно


    1. KanuTaH
      04.08.2023 06:47

      Отдельный std::find_if конечно удобен его разработчику, но не удобен пользователю.

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


    1. Kelbon
      04.08.2023 06:47
      +6

      Не знаю как в С#, но многие языки возвращают на find либо элемент, либо bool, что провоцирует плохой код

      А stl:

      • провоцирует написание эффективного кода(например неэффективных операций просто нет в интерфейсе, это не java где на forward list будет operator[]),

      • унифицирует интерфейс всех контейнеров/view, в том числе пользовательских. Не нужно знать как работать с конкретной коллекцией, у вас есть абстракция итератора и алгоритмы, которые с ними работают, всё просто, если вы посмотрите на другие языки, то окажется, что под каждую "коллекцию" как это там называется часто все методы продублированы и нужно отдельно знать как взаимодействовать с каждой из них. То есть O(N) в голове держать вместо O(1)


    1. SpiderEkb
      04.08.2023 06:47

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

      Примерно то же самое хотел сказать.

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

      И относится это не только к ООП, но и к процедурному программированию в равной степени.


      1. Kelbon
        04.08.2023 06:47

        очень жаль, что вы не сможете сделать vec[10] с такой схемой


        1. SpiderEkb
          04.08.2023 06:47

          Вопрос в том - а нужно ли его делать? А нельзя ли то же самое сделать проще и эффективнее?

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

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


          1. Edric Автор
            04.08.2023 06:47

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

            То есть — да, можно плохо код писать, а можно плохо рефакторить. И то, и другое — плохо.

            На деле в кодовой базе на на несколько сотен тысяч строк кода без нормальных абстракций вы будете все время офигевать. Не говоря уже о кодовой базе на 1M+ LOC и больше.


            1. SpiderEkb
              04.08.2023 06:47

              Адекватный - да. Но далеко не весь рефакторинг бывает адекватным, увы.

              И офигевать можно и от того, что рефакторинг излишне переобогащен абстракциями. Особенно на большой кодовой базе.

              Не говоря уже о том, что после такого рефакторинга нагрузочное регресс-тестировангие может показать отрицательные результаты (работать стало медленнее и тратит больше ресурсов)


    1. Edric Автор
      04.08.2023 06:47
      +4

      Я полностью согласен с тем, что после C# возвращаться к плюсовому STL — это боль. std::ranges из C++20 и C++23 делают жизнь лучше, превращая алгоритмы в компонуемые абстракции — если еще не попробовали, то рекомендую.

      Но до удобства LINQ плюсам конечно очень далеко. Попробуйте набагать в коде с ranges, компилятор выплюнет ошибок на мегабайт. В общем, плюсам есть над чем работать.

      Это все не отменяет того, что STL попилен на абстракции очень правильно, а для своего времени вообще был гениальной библиотекой. В очень многих своих проектах я применял схожий с STL подход — данные отдельно, логика отдельно — и это помогало. И этот подход даже не про C++, случайный пример из головы — Redux из JS.


  1. Kelbon
    04.08.2023 06:47
    +1

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

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


  1. Kelbon
    04.08.2023 06:47

    Насчёт полиморфизма:

    Последнее комбо — это неинтрузивный динамический полиморфизм


    В С++ это чаще называется стиранием типов и действительно очень полезно, более того, зачастую это понятнее/легче/производительнее/расширяемее чем принятый в С++ способ на виртуальных функциях.

    Остаётся только ждать когда люди это осознают и начнут использовать соответствующие инструменты


    1. Edric Автор
      04.08.2023 06:47

      Кстати да, ключевое слово type erasure я не назвал, а это именно оно!


  1. cdriper
    04.08.2023 06:47

    может лучше использовать dynamic_cast как готовое решение, вместо сишного подхода в виде enum для типа и даункастинга?

    а еще std::variant есть...


    1. Edric Автор
      04.08.2023 06:47

      В данном конкретном примере даункаст по enum'у эффективнее, но можно и dynamic_cast использовать, концептуально это ничего не изменит.

      std::variant здесь скорее всего не подойдет, потому что в реальности у вас будет несколько десятков типов событий, может быть даже сотня, засовывать это все в один std::variant — оверкилл.


  1. slonopotamus
    04.08.2023 06:47
    -2

    Итого. Авторы QtFuture спроектировали херовый апи. Авторы strcpy спроектировали херовый апи. И поэтому вы несёте свет в массы и учите читателя. Но читатель-то тут причём? Это не мы задизайнили QFuture и strcpy таким уродским образом. Может быть, надо было им статью отправить?

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

    Плюсик статье, впрочем, поставил.


    1. Sazonov
      04.08.2023 06:47
      +1

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


  1. yKafka
    04.08.2023 06:47

    Отличная статья, читается на одном дыхании, спасибо