Содержание статьи
Когда результат SQL-запроса влечет бесконечные приведения типов ко всевозможным вариантам типов полей. Когда код заполнен малопонятной логикой с гигантским перебором перегрузок по типам boost::variant. Когда не знаешь, как принять аргумент произвольного типа по RPC-протоколу. Тогда требуется механизм эмуляции динамической типизации в C++. Расширяемый и удобный, создающий понятный API. Такой, что не требует предопределенного списка типов и не заставляет работать с указателями на базовый класс. Такой механизм есть — нам поможет двойная диспетчеризация!
Чтобы понять, что такое двойная диспетчеризация и как ее правильно, эффективно и понятно готовить в C++, сначала нужно пояснить, для чего она нужна, и пройти весь эволюционный путь до этого решения. Без этого объяснения начинающий разработчик сойдет с ума к концу чтения, а разработчик опытный, скорее всего, утонет в собственных ассоциациях и сделает неправильные выводы. Поэтому начнем с самого начала — с основ динамической типизации и того, для чего она нужна в C++.
Динамическая типизация в C++
В языке C++ типизация статическая, позволяющая отследить ошибки работы с типовыми операциями еще на стадии компиляции. Как правило, в 90% случаев мы заранее знаем либо тип результата любой операции, либо базовый класс всевозможных значений. Есть, однако, класс задач, где тип значений в результате операции заранее неизвестен и вычисляется на этапе выполнения. Классический пример— результат запроса к базе данных, где в результате выполнения SQL-запроса мы получаем набор сериализованных значений, которые нужно распаковать в соответствующие типы уже после выполнения запроса. Другой пример — это результат вызова удаленной функции через RPC-протокол, результат будет известен на этапе выполнения, и не всегда мы на этапе постановки задачи можем предсказать набор возвращаемых значений, особенно если мы решаем общую задачу с предоставлением промежуточного API для вашего RPC-протокола. Все то же верно для любого потенциально расширяемого функционала, работающего с системой типов, которые удобнее вычислять на этапе выполнения, например те же аргументы функции или параметры SQL-запроса, которые в общем случае удобнее обобщить, но в то же время надо как-то хранить и передавать.
Начнем мы с классического решения через базовый класс и его наследники.
Базовый интерфейс и наследование
Классическое решение через базовый интерфейс в C++ напрямую использует одну из парадигм ООП — полиморфизм. Выделяется общий класс, как правило абстрактный, в нем вводится ряд методов, которые переопределяются в наследниках, и работа ведется со ссылкой либо указателем на тип общего предка значений-наследников.
Рассмотрим небольшой пример. Допустим, у нас есть задача: хранить разные типы товаров на складе. Пусть у любого товара есть наименование, идентификатор категории товара на складе и некая цена. Базовый интерфейсный класс при таком подходе будет выглядеть примерно так:
class IGoods
{
public:
virtual std::string Name() const = 0;
virtual int TypeID() const = 0;
virtual float Price() const = 0;
};
Если, предположим, нам нужно описать такую категорию товаров, как конфетки, то нам нужен класс — наследник базового интерфейса с определенными функциями Name, TypeID и Price, например, так:
class Candies : public IGoods
{
public:
static const int TYPE_ID = 9001;
Candies(std::string const& name, float price);
virtual std::string Name() const override { return m_name; }
virtual int TypeID() const override { return TYPE_ID; }
virtual float Price() const override { return m_price; }
private:
std::string m_name;
float m_price;
};
В результате можно заполнять склад всевозможными товарами, например конфетами, при этом оперируем лишь ссылками на базовый класс. То есть, как правило, нам не нужно знать, какой действительно класс-наследник скрывается за ссылкой, поскольку складу все равно, что в нем хранится, лишь бы можно было прочитать наименование товара, цену и артикул.
Получаем следующие плюсы:
- расширяемость — главный плюс, можно создавать наследники в любой библиотеке и работать с ними на общих правах; так не получится, например, если выбрать метод бесконечного switch, в какой-то момент система задохнется от избытка case-вариантов в разных частях однотипного кода;
- динамическая типизация — по сути, тип можно задать на этапе выполнения, создавая экземпляр того или иного класса-наследника в зависимости от логики задачи, в результате можно, например, заполнить результат разбора JSON-объекта или SQL-запроса;
- наглядность — возможность очень просто построить всем понятную диаграммку с деревом наследников, само описание базового класса подразумевает очевидность поведения класса-наследника.
Есть, однако, и минусы, их всего три, но, игнорируя их, мы получим постоянную головную боль, поскольку лишаемся всех преимуществ классов C++, низводя работу до указателей на базовый интерфейсный класс:
- трудно создавать — что ни говори, а наполнение склада, то есть создание объектов заранее неизвестных классов-наследников, приходится делать через фабрики;
- трудно хранить — вариантов встроенных типов всего два: ссылка и указатель, и лишь указатель можно хранить. Разумеется, хранение контейнера, заполненного указателями, вредно для здоровья приложения, и тут на помощь приходят умные указатели, наподобие std::shared_ptr и std::unique_ptr. Первый довольно тяжел, поведение второго вызывает резкую головную боль при любом копировании, явном или неявном;
- трудно копировать — для случая std::unique_ptr следует озаботиться методом Clone в базовом классе, как, впрочем, и для std::shared_ptr, если мы не планируем ссылаться из разных контейнеров на общие данные. То есть мы либо обманываем пользователя и копирование контейнера не копирует данные в привычном понимании C++, либо еще больше усложняем базовый класс и всех его наследников, добавляя туда примитивную операцию клонирования.
По сути, при таком классическом подходе код в одном месте похож на фильм ужасов и вместо обычного конструктора появляется подобное чудовище:
std::deque<std::unique_ptr<IGoods>> goods;
std::unique_ptr<IGoods> result = GoodsFactory::Create<Candies>();
goods.push_back(std::move(result));
В другом же месте кода начинается форменный ужас при обращении к элементам коллекции через «умные» указатели.
std::deque<std::unique_ptr<IGoods>> another(goods.size());
std::transform(goods.begin(), goods.end(), another.begin(),
[](std::unique_ptr<IGoods>& element) {
return element->Clone();
}
);
Выглядит все это в худших традициях C++, и поэтому неудивительно, что чаще всего разработчики считают нормальными подобные конструкции, даже если они вынесены в интерфейс либо выставлены напоказ в системе с открытым кодом.
Неужели все так плохо в C++ и нельзя обойтись обычными классами с генерируемыми конструкторами копирования и перемещения, оператором присвоения и прочими радостями жизни? Что мешает нам инкапсулировать всю логику работы с указателем на базовый класс в объект класса-контейнера? Да в общем-то, ничего.
Наследование класса данных
Пора немного перестроить логику базового класса. Всю логику работы с базовым интерфейсом мы запакуем в обычный класс C++. Базовый интерфейсный класс перестанет быть абстрактным, и объекты класса получат обычную логику конструкторов и деструкторов, получат возможность копироваться и присваивать значения, но самое главное — мы не потеряем всех плюсов предыдущего подхода, избавляясь от минусов!
Другими словами, базовый класс получает некие данные в виде класса, поведение которого определяется классами-наследниками, у которых класс данных наследуется от класса данных базового класса… правда, звучит запутанно? Сейчас разберем на примере, и все станет понятно.
// Интерфейс класса доступен в общем API
class object
{
public:
object();
virtual ~object();
virtual bool is_null() const;
virtual std::string to_string() const;
protected:
// Только объявление класса данных!
class data;
private:
std::shared_ptr<data> m_data;
};
// Реализация класса данных вне API
// должна быть недоступна для #include
class object::data
{
public:
data() { }
virtual ~data() { }
virtual bool is_null() const { return true; }
virtual std::string to_string() const { return "null"; }
};
// Интерфейс класса доступен в своем API
// не обязательно в той же библиотеке, что и object
class flower : public object
{
public:
flower(std::string const& name);
virtual bool is_null() const override;
virtual std::string to_string() const override;
virtual std::string name() const;
virtual void rename(std::string const& name);
protected:
// Только объявление класса данных!
class data;
};
// Реализация класса данных вне API
// должна быть недоступна для #include
class flower::data : public object::data
{
public:
static const std::string FLOWER_UNKNOWN;
data()
: m_name(FLOWER_UNKNOWN) {
}
data(std::string const& name)
: m_name(name) {
}
virtual bool is_null() const override { return false; }
virtual std::string to_string() const override {
return "flower: " + m_name;
}
virtual std::string name() const { return m_name; }
virtual void rename(std::string const& name) { m_name = name; }
private:
std::string m_name;
};
На самом деле наследников обычно больше, причем они, как правило, появляются в зависимых библиотеках. Теперь пора разобраться, что позволяет эта забавная конструкция.
object rose = flower("rose");
object none;
std::vector<object> garden;
garden.push_back(std::move(rose));
garden.push_back(std::move(none));
garden[1] = flower("gladiolus");
std::for_each(garden.begin(), garden.end(),
[](object const& element) {
std::cout << element.to_string() << std::endl;
}
);
Реализация методов классов API очевидна и проксирует методы работы с данными. Конструктор предка не создает данные и оставляет пустой указатель, конструкторы наследников инициализируют указатель предка наследником данных нужного типа.
Теперь ничто не мешает создать любой новый наследник класса object, задать ему логику преобразования в строку и проверку на наличие значения. Например, можно выделить класс объектов shoes:
class shoes
{
public:
shoes(long long price);
virtual bool is_null() const override;
virtual std::string to_string() const override;
virtual long long price() const;
virtual void discount(long long price);
protected:
class data;
};
Класс shoes::data описывается по аналогии с flower::data. Правда, теперь мы можем получить забавный результат при работе с нашим садом с цветами из предыдущего примера:
garden.push_back(shoes(100000000000LL));
Так, можно оставить в саду обувь стоимостью в 100 миллиардов белорусских рублей. Также на эту обувь нечаянно наткнуться, перебирая цветы, но с той же проблемой мы бы столкнулись и в исходном подходе с интерфейсом на базовый класс. Если бы мы подразумевали, что в саду должны быть исключительно цветы, мы бы сделали std::vector. Судя по всему, автор кода решил хранить в своем саду что попало — от цветов и обуви до заранее неизвестного хлама, включая атомный реактор или египетские пирамиды, ведь ничто не помешает теперь наследовать новые классы от object.
Добро пожаловать в мир динамической типизации с использованием обычных классов C++ с типичной логикой. Хотя нет! Копирование класса приведет всего лишь к копированию ссылки. Пора исправить последнее несоответствие логике классов C++.
Копирование при изменении объекта
Нашему базовому объекту самое время научиться делать то же, что делал исходный интерфейс с помощью метода Clone, то есть копировать содержимое наследника. При этом копирование должно быть максимально щадящим и копировать данные как можно позже. Это условие тем критичнее, чем больше объект и чем интенсивнее его копирование, явное или неявное. Здесь нам поможет принцип копирования при изменении данных объекта.
Копирование при изменении, или copy-on-write (COW), в C++ реализуется сравнительно просто, пример — библиотека Qt, где COW используется повсеместно, в том числе и для строк (QString), что позволяет снизить затраты на копирование данных объектов до необходимого минимума.
Суть подхода в следующем:
- объект ссылается на данные через вспомогательный тип наподобие указателя;
- методы объекта могут быть const и non-const, важно четко выдерживать константность метода, из-за последующих пунктов;
- при вызове метода объекта вызов проксируется на вызов метода класса данных через тот самый вспомогательный тип указателя из первого пункта, у которого для этой цели перегружены два operator ->, для лучшей читаемости, соответственно const и non-const.
Константный вариант перегрузки operator -> просто вызывает нужный метод напрямую у класса данных, проксируя вызов внешнего класса;
неконстантный вариант перегрузки operator -> немного интереснее, он подразумевает, что вызов изменяет данные. Поэтому нужно убедиться, что мы ссылаемся на свои данные, которые можно изменять. Если ссылка на данные не уникальна, то есть мы отложили копирование и ссылаемся на чужие данные, то нужно скопировать себе свою копию данных и работать с ними, вызвав нужный метод.
Копирование при изменении в C++ реализуется сравнительно просто, через перегрузку operator -> у инкапсулируемого вспомогательного класса. Важно перегрузить как const, так и non-const перегрузки оператора
Чтобы было понятно, давай заведем максимально упрощенный вариант такого промежуточного ссылочного типа:
template <class data_type>
class copy_on_write
{
public:
copy_on_write(data_type* data)
: m_data(data) {
}
data_type const* operator -> () const {
return m_data.get();
}
data_type* operator -> () {
if (!m_data.unique())
m_data.reset(new data_type(*m_data));
return m_data.get();
}
private:
std::shared_ptr<data_type> m_data;
};
По-хорошему нужно этот класс обезопасить для многопоточного доступа, как и от исключений в процессе копирования, но, в принципе, класс достаточно простой, чтобы донести основную мысль о реализации COW в C++. Также стоит учесть, что в конструкторе копирования у класса данных подразумевается вызов виртуального метода для клонирования данных.
Теперь все, что нам осталось, — это изменить хранение данных в базовом классе object:
class object
{
...
protected:
class data;
private:
copy_on_write<data> m_data;
};
Таким образом мы получим инициализацию классов наследников, совместимых с базовым классом, то есть по факту динамическую типизацию. Вдобавок мы не оперируем указателями на абстрактный класс, у нас есть привычные классы C++ с конструкторами, деструкторами, копированием и присвоением, максимально упрощенным для создания своих наследников. Единственное усложнение — прокси-методы, сводящиеся к m_data->method(arguments), — оборачивается плюсом, поскольку кроме самого вызова мы получаем возможность сохранять диагностическую информацию, например stack trace, которая упростит нам отслеживание ошибок и генерацию исключений с сохранением последовательности вызовов вплоть до метода, сгенерировавшего исключение.
По сути, мы получили гибрид Pimpl и Double dispatch подходов для динамической типизации данных, для которых тип мы получаем на этапе выполнения.
Реализуем интерфейс класса данных?
Реализуя класс данных, совсем необязательно дублировать все методы внешнего класса, как это делается паттерном Pimpl. Класс данных выполняет две основные задачи: прячет детали инкапсуляции в имплементации и предоставляет доступ к данным в реализации методов внешнего класса. Вполне достаточно сделать get и set методы и некоторый вспомогательный функционал, а обработку данных выполнять непосредственно в методах внешнего класса. Таким образом мы разделяем реализацию класса и детали инкапсуляции.
Применение динамической типизации
Итак, допустим, у нас есть протокол удаленного вызова функций, как вариант, это параметризация SQL-запроса к базе данных. Типы аргументов и результата мы вычисляем на этапе выполнения, если мы делаем общий механизм с предоставлением API конечному пользователю, потому что неизвестно заранее, что пользователь захочет передавать в качестве аргументов и какие типы результата будут получены с удаленной стороны (иногда это неизвестно даже разработчику, пишущему поверх подобного API, потому что при цепочке вызовов аргументы следующего вызова часто основываются на результатах предыдущего).
В подобных случаях, когда базовый класс является не только интерфейсом для наследников, но и контейнером для данных наследника, мы получаем возможность описывать любой функционал, в котором требуется динамическая типизация в терминах классов и объектов C++.
Рассмотрим пример SQL-запроса. Список аргументов для выполнения запрос можно сгенерировать тем же Boost.Preprocessor для функции от произвольного числа аргументов типа object.
// Готовим SQL-запрос, реализация db::SqlQuery неважна
db::SqlQuery query("select * from users as u
where u.type = $(usertype)
and u.registered >= $(datetime)
limit 10");
// Выполняем запрос перегруженным operator ()
db::SqlQueryResult result = query("admin", datetime::today());
// Обходим значения
std::for_each(result.begin(), result.end(),
[](db::SqlQueryRow const& row)
{
// Работаем с логином
object login = row["login"];
if (login.is_null())
std::cout << "not specified";
else
std::cout << row["login"];
// Обрабатываем статус
if (row["status"] == "deleted")
std::cout << " (deleted)";
std::cout << std::endl;
}
);
В качестве аргументов db::SqlQuery::operator() можно использовать произвольный набор object, в этом случае нужно определить шаблонный implicit конструктор приведения типов к общему типу object:
class object
{
public:
template <typename value_type>
object(value_type const& value);
...
};
В этом случае нам потребуются наследники от класса object вида integer, boolean, floating, text, datetime и прочие, данные которых будут помещаться в object при инициализации объекта соответствующим значением. В этом случае инициализация объекта произвольным типом будет расширяемой и все, что нужно будет, чтобы задать объект нужным типом, — это написать соответствующую специализацию, наподобие этой для bool:
class boolean
{
public:
boolean(bool value)
: object(value) {
}
...
protected:
class data;
friend class object;
};
template<>
object::object(bool value)
: m_data(new boolean::data(value)) {
}
Самое важное здесь другое, результат выполнения запроса — таблица с данными, вычисленными на удаленной стороне базы данных в момент выполнения. Однако мы совершенно спокойно можем обходить каждую строку результата запроса, получая object совершенно определенного типа, а не какие-то недодесериализованные данные. С объектом можно работать, ему можно перегрузить операции сравнения, можно по аналогии с конструктором сделать шаблонный метод получения значения определенного типа, можно привести к строке, вывести в поток. Объект типа object у нас вполне себе контейнер, которым можно оперировать как обычным объектом класса.
Мало того, при желании можно добавить в object логику контейнера и обойтись вообще одним типом на любое значение, возвращаемое из запроса. То есть перегрузив ему методы begin(), end(), size(), а также operator [] :
object result = query("admin", datetime::today());
std::for_each(result.begin(), result.end(),
[](object const& row) {
std::for_each(row.begin(), row.end(),
[](object const& cell) {
std::cout << cell.to_string() << ' ';
}
std::cout << std::endl;
}
);
В принципе, идею можно доработать до того, что можно вообще что угодно использовать через контейнер и базовый класс object, но здесь не стоит забывать о здравом смысле. Идея статической типизации, выявляющей ошибки еще на этапе компиляции, очень хороша, и отказываться от нее везде, где только можно надуманно использовать типизацию динамическую, крайне неразумно!
ВЫНОС ИЗ ТЕКСТА
Идея статической типизации, выявляющей ошибки еще на этапе компиляции, очень хороша, и отказываться от нее повсеместно крайне неразумно!
Тем не менее динамическая типизация крайне полезна в тех самых местах, для которых она предназначена, — для значений, чей тип получается динамически, как правило в результате разбора потока данных. Инкапсулированный в базовом классе интерфейс для работы с данными различного типа позволяет нам работать с обычными объектами C++, создавая и копируя их обычными конструкторами, и оператором присвоения, причем копирование можно сделать максимально отложенным (в идеале навсегда) с помощью техники copy-on-write.
В одно и то же время базовый класс является как классом интерфейса для работы с различными данными, так и контейнером. Для удобства можно определить для базового класса все необходимые операции: сравнения, индексации, математические и логические операции и операции с потоками. В целом можно реализовать максимально читаемый и логичный код, наиболее защищенный от ошибок хранения указателя на базовый класс, копирования и доступа из разных потоков. Особенно это полезно, если этот API разрабатывается под широкий круг задач, при работе с набором типов, которые изначально неизвестны, и сам набор типов может потенциально расширяться.
Динамическая типизация — это взятая на себя ответственность!
Нужно быть предельно предусмотрительным при вводе динамической типизации. Помни, что разработчики на скриптовых языках часто завидуют возможности C++, C# и Java проверять типы еще до выполнения алгоритма на этапе компиляции. Используй силу статической типизации, эмулируя отказ от нее лишь там, где это оправданно! Как правило, динамическая типизация нужна для выполнения обобщенного API-запроса к удаленному серверу сериализованных данных (в том числе и запрос к базе данных).
После десериализации уже на этапе выполнения может получиться целый ряд типов. Отказываться от типов, полученных динамически, и работать с сериализованными в текст или байтовый поток данными обычно неоправданно, поскольку при получении данных, как правило, требуется обработка. Удобство разобрать данные и получить привычные типы C++, работая не с указателями на интерфейс, а с обычными объектами грамотно сконструированных классов, — бесценно.
Новый путь
Мы получаем, по сути, удобный способ создания API для C++ библиотек, реализующих RPC-протокол или взаимодействие с базой данных либо работающих с обобщенным типом объектов на некотором этапе обработки. Можно даже сделать некоторый функционал для себя на каждый день, ведь мы очень часто работаем с данными, чей тип мы узнаем уже на этапе выполнения. Усложнение кода получается минимальным, класс данных реализует лишь доступ к инкапсулированным данным класса, всю остальную работу внешний класс решает самостоятельно. Для реализации copy-on-write потребуется несложный шаблон класса с двумя перегрузками operator -> для const и non-const вызовов, причем это необязательный аспект, если, например, все данные скалярного типа и передавать их по ссылке не имеет смысла. Остается лишь проблема с массовым динамическим выделением памяти и фрагментацией памяти, однако если эта проблема и возникнет, то решается через пул объектов, базовый тип которых у нас уже есть. Как оптимизировать массовое выделение памяти и работа с размещающим new — уже совсем другая история.
Главное, что для создания объектов мы пользуемся конструктором, а не фабрикой. Конструкторы копирования и перемещения нам подойдут и автоматически генерируемые, и они будут работать, по-честному копируя данные, но только в случае изменения. Предварительное объявление класса данных и вынос описания его интерфейса в область реализации дает нам возможность безболезненно изменять инкапсулированные в класс данные от версии к версии. При этом не нужно проксировать все подряд методы, как это часто делается при реализации паттерна Pimpl, класс данных нужен именно для доступа к данным, не более.
Мы получаем превосходный API, используя привычную логику классов C++. За внешней простотой спрятан мощный механизм обработки динамически типизируемых данных, который позволяет создавать и копировать данные различных типов не раньше чем это необходимо. Разнотипные данные получают возможность храниться в обобщенном классе, который объединяет логику интерфейса и функциональность контейнера, что защищает от обычных ошибок при работе с указателем на класс-интерфейс в классическом подходе.
Пользователь такого API получает все, и это не будет стоить нам практически ничего. Все, что нужно, — это просто сделать хороший API, используя новый путь.