Как стать автором
Обновить

О бедном C++ API замолвите словцо!

Время на прочтение15 мин
Количество просмотров42K
Желание написать об C++ API у меня возникло давно, и вот наконец выдался спокойный вечер. По роду деятельности я и мои ребята пишем код на C++ для программистов на C++ и Python, общее ядро функционала, который используется во всех продуктах нашей компании. Разумеется это подразумевает, что код должен иметь интуитивно понятный API, с общей логикой как для низкоуровневого C++, так и для высокоуровневого Python, вне зависимости от разночтения в языках некоторых базовых конструкций. Об объединении C++ и Python я много писал ранее в статьях про Boost.Python, сейчас я очень благодарен архитектуре и логике языка Python, я многое понял и перенял в С++ именно благодаря опыту построения общего API для этих двух таких разных языков, но сейчас речь пойдёт только и исключительно о C++, про API и про то, что такой зверский гибкий язык позволяет сделать с интерфейсом вашей замечательной библиотеки, если не учитывать ряд важных особенностей языка C++.

Present perfect coninuous


Итак, на дворе 2014 год. Язык C++ (в отличие от его предка-подмножества языка Си) обладает всем потенциалом языка высокого уровня, который уже на протяжении многих лет остаётся не более чем потенциалом. Если разработчику не хватает чего-то высокоуровневого, придётся либо изобретать что-то своё, либо пытаться найти готовое решение, благо источников готового кода более чем предостаточно. Это и старый-добрый STL, который в C++11 немного заштопали и обновили, это и монструозный Boost, с разношёрстными библиотеками разной степени готовности, есть и Qt с его интерфейсными и не только плюшками, а также море свободно распространяемого софта с разной степени заразности лицензиями. Я не буду сравнивать богатство библиотек C++ с библиотеками для языков Java и Python, всё-таки языки решают разные задачи, но каждому очевидно, что возможности написать на C++ что-то высокоуровневое без жёсткого порно траты лишнего времени не выйдет. Итак, рано или поздно, определив свою нишу, любой разработчик С++ пишет для себя или для общества, возможно для коллег, некий общий функционал, объединяя разношёрстное разноцветия множества API в единую стройную структуру, в поиска своего дзена проектирования API. Бывают конечно люди, предпочитающие только пользоваться библиотеками, написав код, за который им платят, не интересуясь красотой построения программного интерфейса, променяв его на мирскую суету, но речь сейчас не о них… Эта статья для тянущихся к свету истинного проектирования прекрасного API языка C++, ну и немного для тех, кого к этому свету насильственно притягивают за уши.

Классы


Как ни странно, но именно классы являются тем самым слабым звеном вашего API, если вы стремитесь сделать интерфейс вашей библиотеки максимально прозрачным и предсказуемым. Причём даже без учёта шаблонов этих самых классов. Если немного окунуться в историю, то класс в C++ это лишь надстройка на структурой struct в языке Си. Все свои поля содержит по значению, то есть является ничем иным как совокупностью своих полей с некоторой надстройкой ООП. Все кто как-либо касался Managed C++ расширения языка C++ для платформы .NET, помнят, что классы могут ссылаться на свои данные по ссылке, а могут по значению. Мы не будем обсуждать ужасы тонкости взаимодействия языка C++ с фреймворком .NET, просто заметим, что в обычном C++ данные класса всегда хранятся по значению, даже если это значение — ссылка или указатель. Так что мы не очень далеко ушли от языка Си, в котором по значению передавались даже аргументы в функцию и без вариантов, хочешь ссылку — передай указатель. Всё это имеет неоспоримые преимущества при размещении на стеке временных переменных и это же размещение данных по значению является вероятно самой большой проблемой при архитектуре более-менее высокоуровневой библиотеки, подразумевающей интенсивное развитие, выпуск новых версий и совместимый программный интерфейс с каждой последующей версией. Не суть важно как вы называете классы, пространства имён (namespaces), методы и поля, главное то, как вы прячете от вашего любимого пользователя вашей библиотеки детали реализации, всё то что ему знать не нужно (кому нужно сам залезет в .cpp или .cxx файлы и поглядит детали реализации) ведь в заголовочном файле не должно быть ничего кроме API, по возможности следует даже убрать все лишние #include, заменив на предварительное объявление (forward declaration) все используемые по ссылке типы…
… включая тип данных самого класса!
Да-да-да! Если есть возможность держать данные класса по ссылке, то данные можно объявить отдельным классом без реализации в заголовочном файле и вынести его целиком в .cpp-файл с реализацией методов. То есть API вашего класса сводится к следующей схеме:

// до этого должно быть объявлен аналог SOME_API переключая import / export при сборке (если необходимо!)
// мы же разрабатываем кроссплатформенную библиотеку с независимым от системы сборки API (если нет, убираем аналог SOME_API)
class SOME_API something
{
public:
    // здесь куча методов, конструкторов и операторов

private:
    class data; // неважно что там в классе, а если вы собираете SDK, не показывая исходников, этого никто и не узнает

    std::shared_ptr<data> m_data; // не лучший вариант, но пока не рассмотрели copy-on-write модель это и понятно и безобидно
};


Аналог этого кода будет в вашем заголовочном файле вне зависимости от расширения: .h, .hpp, .hxx либо вообще его отсутствия. Здесь не так важно именование, как важен принцип. Вне зависимости от наполнения класса something::data мы можем не менять файл с API класса something, точнее не теряя совместимость с его предыдущими версиями. Но и это ещё не всё! © Классический подход с обычным хранением по значению таит в себе ещё целый выводок подводных скал, на которых нас несёт течение языка C++ с поведением объектов вашего класса по умолчанию.
Чтобы полнее ощутить всю прелесть хранения по значению рассмотрим небольшой пример:

// здесь и далее в примерах аналог SOME_API опускаем
class person
{
public:
    // не суть важно с какой стороны от типа вы пишете const (хотя для констант указателей это важно)
    void set_name(std::string const& name);

    // по-хорошему возвращать результат нужно по значению, но в STL от MS данные std::string будет копироваться
    // почему это так, и что такое copy-on-write, об этом ниже
    std::string const& get_name() const;

    // дадим полный доступ для vector of child, просто потому что это пример, удобный для объяснения
    // но такой способ возврата приковывает нас к единственному способу хранения поля m_children, поэтому так делать плохо
    std::vector<person>& get_children();

    // для того чтобы и здесь вернуть объект списка детей по значению, нужно завести отдельный класс с удобным API
    // либо сделать ряд удобных методов вида add_child, get_child и т.п.
    std::vector<person> const& get_children() const;

private:
    std::string m_name; // какое-то имя человека, представимого классом person
    std::vector<person> m_children; // список детей человека, каждый из которых тоже почему-то человек
};


Итак, что мы здесь видим? Конструктор по умолчанию, который порой суров к POD-типам, здесь генерируется вполне сносный, поскольку поля — стандартные контейнеры STL, у них с инициализацией по умолчанию всё в порядке. В целом вы должны иметь в виду, что конструкторы имеют свойство генерироваться: конструктор по умолчанию, конструктор копирования и в C++11 ещё и конструктор перемещения, также сгенерируются оператор копирования и для C++11 оператор перемещения.
И если для инициализации и перемещения здесь всё чисто благодаря STL, то вот с копированием благодаря тем же контейнерам STL мы получим ад рекурсивного копирования.
Простая операция:

person neighbour = granny;

может привести к адской головной боли несчастного разработчика, использующего ваш класс person. Представьте что в переменной granny находится построенный объект некой бабушки с богатым потомством, у неё есть куча детей и по каждому из детей ещё и на порядок больше внуков. По всем правилам все эти замечательные потомки бабушки начнут клонироваться как амёбы в объект neighbour. Так будет конечно же безопасно для бабушки, в плане дзена С++ и контейнеров STL, но совсем небезопасно для её психики, да и для оптимизации выполнения базовых операций при работе с вашим классом тоже очень и очень плохо.
Нерадивый разработчик скажет: «ой, да ладно, разберутся, не маленькие», и несчастные пользователи библиотеки будут думать, как же забороть тот самый вектор внутри каждого объекта, который рекурсивно усложняет проблему неявного копирования. И скорее всего найдут другую библиотеку, которая и спроектирована лучше, и работает более прозрачно и предсказуемо.
Неожидаемое поведение при очевидных операциях — это головная боль разработчика функционала и проектировщика API, а уж никак не несчастного пользователя их библиотеки. В крайнем случае опытные разработчики обойдут эту проблему, помещая такие опасные классы в обёртки с умными указателями, но в целом так делать нельзя. Давайте вылечим бабушку и её потомков от синхронного амёбного деления. Не оставлять же её тут в таком виде!

// это в заголовочном файле
class person
{
public:
    // нам пока понадобится конструктор по-умолчанию, потом мы от него избавимся
    person();

    // задать имя, здесь без вариантов
    void set_name(std::string const& name);

    // получить константную ссылку на имя
    // (мы помним что возвращение ссылки ограничивает возможности реализации!)
    std::string const& get_name() const;

    // получить ссылку на список потомков
    // (мы помним что возвращение ссылки ограничивает возможности реализации!)
    std::vector<person>& get_children();

    // получить константную ссылку на список потомков
    // (мы помним что возвращение ссылки ограничивает возможности реализации!)
    std::vector<person> const& get_children() const;

private:
    class data; // вообще для double dispatch лучше перенести class data в protected, но это отдельная тема

    std::shared_ptr<data> m_data; // общие данные могут быть проблемой, об этом ниже
};

// это в файле спрятанном в реализации, возможно в отдельном заголовочном
class person::data
{
public:
    void set_name(std::string const& name);
    std::string const& get_name() const;

    std::vector<person>& get_children();
    std::vector<person> const& get_children() const;
    
private:
    std::string m_name;
    std::vector<person> m_children;
};

// это уже детали реализации
person::person()
    : m_data(new data) // можно сделать инициализацию ленивой, т. е. по первому требованию данных
{
}

// далее множество методов person пробрасывающих вызов в person::data
// возможно с какими-то дополнительными действиями, например логированием или сохранением стека вызовов

// для примера:
void person::set_name(std::string const& name)
{
    // если нужно, вставляем проверку на ленивую инициализацию
    m_data->set_name(name); // здесь можно перегрузить operator-> у некоего внутреннего класса, об этом ниже
}


Итак бабушка перестала копироваться. Совсем. Что тоже не совсем правильно в концепции C++, с этим мы отдельно разберёмся. В принципе можно взять за основу Java-style и все подобные суровые значения передавать по ссылке и только по ссылке, но это значит обмануть пользователя вашей библиотеки в ситуации, когда его можно и не обманывать. Что нам мешает копировать данные этой бабушки только при изменении данных?

Copy-on-write


Метод копирования при изменении довольно прост и его довольно быстро можно реализовать самому, причём потокобезопасно (при условии потокобезопасности реализации std::shared_ptr стандартной библиотеки C++). Суть метода C-o-w в следующем, пока объект передаётся в методы как const this, данные разделяются между всеми копиями объекта порождённого от одних и тех же данных. Однако как только this становится неконстантным, любой из методов (бывают правда исключения) не помеченный как const в первую очередь проверяет std::shared_ptr::is_unique() и если на одни и те же данные ссылается больше одного объекта, мы отцепляем себе свою уникальную копию от общих данных и её правим. В принципе можно обойтись даже и без мьютекса, в худшем случае лишний раз скопируем объект, что не смертельно для тестового примера при объяснении данной темы. Реализуется же данный механизм проще всего через перегрузку operator-> для const и не-const случая this объекта промежуточного шаблонного класса, шаблонного, поскольку механизм общий. Выглядит этот промежуточный шаблон примерно так:

// крайне ленивый класс! лениво создаёт и копирует данные, то что нужно!
template <class data>
class copy_on_write
{
public:
    // обычный вызов метода, если данные ещё не были созданы, то они инициализируются
    data const* operator -> () const;

    // вызов метода, подразумевающий изменение данных
    // если ссылка на данные не уникальная, мы "отцепим" себе свою копию данных
    data* operator -> ();

private:
    mutable std::shared_ptr<data> m_data; // mutable он исключительно для ленивой инициализации в const вызовах

    void ensure_initialized() const; // реализуем ленивую инициализацию
    void ensure_unique(); // реализуем ленивое копирование
};

// реализация довольно проста
template <class data>
data const* copy_on_write<data>::operator -> () const
{
    ensure_initialized(); // убеждаемся что мы не ссылаемся на nullptr
    return m_data.get(); // возвращаем константную ссылку на данные для вызова const-метода
}

template <class data>
data* copy_on_write<data>::operator -> ()
{
    ensure_unique(); // убеждаемся, что мы единственные владельцы ссылки на общие данные
    return m_data.get(); // возвращаем ссылку на данные для вызова метода потенциально изменяющего данные
}

template <class data>
void copy_on_write<data>::ensure_initialized() const
{
    if (!m_data)
    {
        m_data.reset(new data); // потребуется конструктор по умолчанию, но в принципе с помощью type_traits это можно обойти
    }
}

template <class data>
void copy_on_write<data>::ensure_unique()
{
    ensure_initialized(); // в любом случае данные должны быть инициализированы

    if (!m_data.unique()) // метод unique() шаблона класса std::shared_ptr проверяет уникальность ссылки
    {
        m_data.reset(new data(*m_data)); // конструктор копирования класса данных должен быть доступен
    }
}

Всё что осталось, дать нашему классу person возможность жить в своё удовольствие, создавать безнаказанно вектора потомков person любой длины (они будут неинициализированы до первого обращения к данным), копировать, передавать по значение в функции (никаких const& не нужно, пусть это и чуть подороже), возвращать в качестве результата тоже по значению (опять же никаких const& и неявных ошибок из-за этого!) Но самое главное, что когда мы начнём изменять данные какого-либо объекта, скопируется только тот объект, который изменяют, от рекурсивного копирования останется только видимость!
Итак, person, живи полной жизнью и ни в чём себе не отказывай:

class person
{
public:
    // все методы остаются, конструктор по умолчанию смело убираем!

private:
    class data; // всё так же ссылаемся на данные, которые реализуем вне заголовочного файла с API

    // вот этот магический шаблон дарит нам возможность свободно дышать ссылаясь на данные класса
    copy_on_write<data> m_data; 
};

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

Константность при вызове метода


Разумеется метод выдающий ссылку на вектор — зло, крайне неудачное решение для Copy-on-write модели, особенно перегрузкой метода для const и не-const this. Просто потому что неявно может быть копирование там, где неявно используется неконстантная перегрузка. Ведь пользователь вашего API не обязан заботиться о константности своей переменной, например заводя переменную на стеке, разработчик получит неконстантную переменную. Поэтому нужно внимательно следить за тем, чтобы от константности не зависила судьба копирования объектов вашего класса. По крайней мере пусть это будет очевидно.

class person
{
public:
    void set_name(std::string const& name); // единственный метод, с которым изначально всё было в порядке

    std::string get_name() const; // убирая ссылку на std::string мы избавляемся от последней привязки API к реализации

    int get_child_count() const; // без количества потомков работать со списком потомков толком не получится

    person get_child(int index) const; // получаем потомка по индексу, учитывая что уникальность потомков именно в индексе

    void add_child(person); // просто добавляем потомка, не заставляя указывать индекс

    void set_child(int index, person const& child); // передавать объекты класса person лучше всё же по ссылке

private:
    class data; // всё так же ссылаемся на данные, которые реализуем вне заголовочного файла с API

    // вот этот магический шаблон дарит нам возможность свободно дышать ссылаясь на данные класса
    copy_on_write<data> m_data; 
};

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

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


Бонусом получаем более шуструю компиляцию, просто потому что объявление полей person::data вынесено в отдельный файл скрытый в реализации и не нужно компилировать
#include , как в общем и сам дополнительный класс при использовании класса person извне. В принципе и сам std::string можно изменить на forward declaration, тогда в заголовочном файле можно избежать ещё и #include , ещё больше ускорив компиляцию при использовании вашего класса. Сделать это можно объявлением такого вида:

// можно вынести все эти объявления в файл вида <stdfwd> namespace std { template <typename char_type> class allocator; template <typename char_type> struct char_traits; template <typename char_type, typename traits_type, typename allocator_type> class basic_string; // forward declaration для типа std::string typedef basic_string<char, char_traits<char>, allocator<char>> string; // forward declaration для типа std::wstring typedef basic_string<wchar_t, char_traits<wchar_t>, allocator<wchar_t>> wstring; // здесь же можно добавить, чтоб не подключать лишний #include <cstddef> для std::nullptr_t typedef decltype(nullptr) nullptr_t; }

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

Ещё пара слов о выпечке хлеба


Все кто хоть раз читал о том "Как два программиста хлеб пекли" запомнил эту статью навсегда. Несмотря на довольно забавное повествование, статья учит главному: правильно структурировать свой код, избегая лишних сущностей. Но не все понимают, что к этим лишним сущностям приводит, а факторов всего три:
1) недостаток опыта построения API (попробуйте сами его использовать, напишите хотя бы пару тестов... ну как, удобно пользоваться?)
2) перенасыщение новым материалом красивых структурированных паттернов, которые на страницах книги выглядят так заманчиво
3) преобладание энтузиазма попробовать что-то новое над устоявшимися принципами построения API, как следствие двух предыдущих пунктов.

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

1. Странные фабрики типов, которые создают что-то заранее типа неизвестное, либо просто без основания создаётся что-то разнотипное, унаследования от одного класса-предка, обычно попытки воссоздать interface-класс из высокоуровневых языков (бывает что и без виртуального деструктора). В большинстве случаев заменяется банальным конструктором объекта-контейнера того самого интерфейса. Вместо этого над пользователем издеваются, предлагая создавать код вида:

std::unique_ptr<IAmUselessInterface> something = UserUsefulFactory::CreateSomethingLessUseless<DerivedUsefulClass>(arguments);

И это заставляют делать пользователя библиотеки, вместо того, чтобы просто немного потрудиться, реализовав double dispatch, если действительно нужно наследование, но как правило хватает банального контейнера для ссылки на одного-двух наследников, указатель на предка которых можно просто поместить в private. В результате пользователь будет просто создавать обычные объекты C++ простейшим конструктором, не задумываясь о работе с каким-то указателем на интерфейс, работая с API обычного класса-контейнера.

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

std::unique_ptr<IХлеб> something = ФабрикаХлеба::Instance().СоздайПирожок<ПирожокСПовидлом>(
                                                           ФабрикаТеста::Instance().ДайТеста<ТестоДляПирожков>(42));
// и это всё вместо примерно такого:   Хлеб пирожок(Тесто<ДляПирожка>(42));

3. Засилье наследования. Просто запомните важное правило: там где можно наследование эффективно заменить на инкапсуляцию, нужно использовать инкапсуляцию, а не наследование. Если сомневаетесь, что использовать: наследование или инкапсуляцию - используйте инкапсуляцию. Просто потому что наследование подразумевает ряд проблем, которые вы перекладываете на пользователя вашего API, причём одна из них - это хранение где-то созданного экземпляра класса-наследника, причём видимо указателем на базовый класс, который вероятно абстрактный. Я не спорю, если C++ даёт вам возможность вытворять с классом что угодно вы вправе это делать, но вряд ли пользователь скажет спасибо от засилья наследников никому непонятного класса, который вы ввели просто потому что захотелось хоть какую-то иерархию классов. Действительно, если у сущностей Крокодил и Камень есть общая сущность МаяПридумалНовыйКласс, почему бы её не завести и не вытащить в API как общий базовый класс объединяющих данные две сущности.

Посмотрите например на реализацию интерфейса библиотеки Boost.Python. Класс object из namespace boost::python не столько предок остальных классов, представляющих объекты языка Python внутри C++, сколько контейнер для PyObject*, которым никто пользоваться не заставляет. Нужный объект PyObject* просто создаётся простым конструктором класса object по типу аргумента конструктора (конструктор по умолчанию кстати создаёт None - аналог NULL-значения в Python). Да, здесь есть наследование, но никто не заставляет работать с тем же boost::python::dict как со ссылкой на boost::python::object с кучей перегруженных методов. Нет, здесь выбран подход инкапсуляции и наследование лишь помогает, например при передаче аргументов, позволяя обобщить тип объекта, например в dict до того же object.

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

class person
{
public:
    // здесь всё остаётся по-прежнему

protected:
    class data;

    data& get_data_reference();
    data const& get_data_const_reference() const;

private:
    copy_on_write<data> m_data;
};

class VIP : public person
{
public:
    // здесь можно добавить ещё методов

protected:
    class data; // класс VIP::data - наследник класса person::data
};

В результате можно выполнить такой код:

person president = VIP("mr. President");

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

Послесловие


Создавайте самое удобное API, удивляйте приятно, пусть каждый пользователь вашей библиотеки приводит её в пример всем и каждому в восторге от её использования.
Может вы с этого ничего и не получите, вероятно проще было бы наваять тяп-ляп-API, да и быстрее будет, но этого ли вы хотите от разработки библиотеки предназначенной таким же как и вы разработчикам?
Конечно нет! Разработка - это не просто работа, это - удовольствие от самосовершенствования, искусство познавать и умение применять знания с толком и по делу. Ведь так приятно, когда твоя работа приносит удовольствие не только тебе, но и многим людям вокруг!

Полезные ссылки


Копирование при изменении (Copy-on-write).
Двойная диспетчеризация (Double dispatch)
Проект с кодом приводящимся в статье выложен сюда - скачивайте, пробуйте, экспериментируйте.
Теги:
Хабы:
Всего голосов 38: ↑29 и ↓9+20
Комментарии71

Публикации

Истории

Работа

Программист C++
103 вакансии
QT разработчик
3 вакансии

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань