Когда результат 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 и 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, используя новый путь.
Впервые опубликовано в журнале Хакер #189.
Автор: Владимир Qualab Керимов, ведущий С++ разработчик компании Parallels
Подпишись на «Хакер»
Комментарии (24)
Unrul
14.05.2015 14:42Как я понимаю, всё это уже есть в Boost.TypeErasure. Об использовании подробно рассказывалось в «Andrzej's C++ blog».
Qualab
15.05.2015 21:15Похоже, но здесь нет ни одного шаблона снаружи (в отличие от type_erasure), а в статье как раз целый раздел посвящён тому, за что можно не любить шаблоны. Подход в статье также не призывает делать лишние cast'ы. К тому же можно свободно использовать наследники, не заморачиваясь только на object, как в Boost на any.
Unrul
16.05.2015 12:39В самой статье шаблоны часто используются. Касты, наследники и общие типы не обязательны. Тем более, что с type_erasure получается значительно короче и лаконичнее:
BOOST_TYPE_ERASURE_MEMBER((has_name), Name, 0) BOOST_TYPE_ERASURE_MEMBER((has_price), Price, 0) namespace te = boost::type_erasure; using Goods = boost::mpl::vector<te::copy_constructible<>, has_name<std::string(), te::_self>, has_price<float(), te::_self>, te::relaxed>; using AnyGoods = te::any<Goods>; // Протестируем class Candies { public: Candies(std::string const &mName, float mPrice) : m_name(mName), m_price(mPrice) {} std::string Name() const { return m_name; } float Price() const { return m_price; } private: std::string m_name; float m_price; }; class EmptyGoods { public: std::string Name() const { return "empty"; } float Price() const { return 0; } }; TEST(AnyGoods_Should, contain_various_types) { AnyGoods goods1 = Candies("test", 123); AnyGoods goods2 = EmptyGoods(); EXPECT_THAT(goods1.Name(), Eq("test")); EXPECT_THAT(goods1.Price(), Eq(123)); EXPECT_THAT(goods2.Name(), Eq("empty")); EXPECT_THAT(goods2.Price(), Eq(0)); }
Qualab
18.05.2015 17:53Ну если тебе нравится подход с вектором из кучи элементов в виде mpl-каши, то тоже вариант. Но вообще мой подход даёт возможность писать классы, просто описывая классы как обычно, просто они становятся юзабельными по значению, независимо от размера, прячут данные в реализацию и могут быть контейнерами для наследников. А так конечно можно и на void* всё то же самое сделать.
RPG18
14.05.2015 15:18Я не понимаю зачем это делать для SQL.
Вот пример использования libpqxx
const std::string query = "SELECT id, type_id, measurement_id FROM qoscfg.kpi"; pqxx::work work(*m_connection, "GetKpii"); const auto res = work.exec(query); for (auto i = res.begin(), r_end =res.end(); i != r_end; ++i) { size_t id = 0; (*i)[0].to(id); std::string type; (*i)[1].to(type); size_t mid = 0; (*i)[2].to(mid); const auto kpi = std::make_shared<Kpi>(id, type, m_measurement[mid]); list.push_back(kpi); }
Qualab
15.05.2015 21:05Затем, что в большинстве случаев тебе надо запаковывать результат запроса в компактный набор значений, неопределённого на этапе компиляции типа, причём память, желательно, выделить однажды, а значениями всё забить так, чтобы с набором было удобно работать.
RPG18
16.05.2015 20:01А это как вы системы запроектируете. Я проектирую слой работы с БД так, что бы таких случаев не было, у меня всегда типы известны.
Qualab
16.05.2015 20:50Если делать SQL-конструктор на основе конструкций C++ или просто даже библиотеку общего пользования, то на этапе компиляции знать типы того, что придёт из БД не известно. То же касается и RPC.
RPG18
16.05.2015 22:36У базы данных есть схема, в которой четко прописаны типы. Я привел пример с работой PostgreSQL, там в протоколе содержится информация о типах. Я разрабатываю базу в терминах предметной области и мне удобно опрерировать простым набором объектов, которые соответствуют таблицам.
В случае RPC должна быть некая конвенция о формате и типах данных. Например в случае с JSON-RPC я использую JSON схему для валидации.
SQL-конструкторы появляются в разработке своей ORM, или разработка всяческих конструкторов схем данных. Там действительно схема данных определяется пользователем, а не программистом.Qualab
16.05.2015 23:01Пользователь ORM-системы или SQL-генератора, наподобие LINQ является точно такой же программист, которому нужен удобный API, эффективность при выполнении инструкций и интуитивно понятный код в результате разработки. Разработчик библиотеки в свою очередь разумеется не знает заранее о том, что за типы придут с некой заранее неизвестной базы данных или с удалённого клиента RPC-протокола.
RPG18
18.05.2015 12:26очередь разумеется не знает
А вот тут вы лукавите. Кое что разработчик API сделать может. Например разработчики libpqxx сделали такой метод, для извлечения результата:
template<typename T > bool pqxx::field::to (T & Obj) const
Отсюда имеем ситуацию:
- я как пользователь знаю какие типы должен возвращать запрос;
- разработчики API ввели ограничения и сказали явно указывайте тип;
- во время выполнения библиотека знает какие типы пришли в запросе, какие типы указал пользователь и делает преобразование типов проверив на возможность такого действия.
и все это без таких сложностей, какие привели вы.Qualab
18.05.2015 13:24Неправда Ваша, смотрите, то что я предлагаю — это по сути аналог pqxx::field, только в более широком спектре использования. Никто не мешает сделать так:
template <typename value_type> value_type object::to() const;
Скажу больше, ровно так и нужно сделать. Обобщённый тип всегда должен давать возможность работать с содержимым в виде нативного значения.
rumkin
14.05.2015 16:20-2К разговору о динамической типизации. Если мне нужно передать некоторое значение, которое будет приводиться к конкретному типу, основываясь на внутреннем состоянии, то как мне указать в функции, что аргумент не имеет определенного типа?
void SomeClass::DoSomething(??? * data) { switch(this->state) { case 1: int * value = reinterpret_cast<int>(data); // do something with value break; case 2: std::string * value = reinterpret_cast<std::string>(data); // do something with value break; } }
Вариант использовать разные функции не подходит, так как принцип работы сложнее, чем в примере. Конечно, можно использовать структуру в которую и поместить data, но может есть более лаконичное решение.
maydjin
14.05.2015 21:17Описанное, больше похоже на реализацию pimpl. Ну, и на динамическую типизацию не особо тянет пока что.
Кстати, у Майерса в его книженции про 11ый есть ряд рекомендаций по типовой реализации pimpl, не совсеми я согласен, но почитать стоит.Qualab
15.05.2015 21:09Это не pimpl, я понимаю откуда такая аналогия. По сути это инкапсуляция интерфейса и подразумевает наследование. Pimpl — по сути просто класс с реализацией, с прокси-декоратором снаружи. У одного класса, в подходе Майерса, есть как правило класс двойник, и инкапсулировать прокси-класс с API может только его. Здесь подход именно на том, что наследование внутренних классов с данными инкапсулировано. Мы запросто можем положить в object любой его наследник.
pdima
15.05.2015 12:50+1Всегда думал что двойная диспетчеризация немного о другом:
en.wikipedia.org/wiki/Double_dispatchQualab
15.05.2015 21:10Это именно она и есть, просто скрещена с Pimpl подходом. Можно чуть перестроить, чтобы было удобнее видеть параллели, но так как в статье удобнее использовать и читать понятнее.
AndrewAZ
16.05.2015 00:15Есть минус — невозможны взаимные ссылки по таким прокси-объектам.
class A { B b; }; class B { A a; };
По простым (или умным) указателям-то с forward-декларацией разруливается.
Из плюсов — можно операторы правильно перегружать.
Qualab
16.05.2015 20:52Почему это невозможны, вполне можно напихать объекты классов друг в друга, только не в сами классы, а в их классы данных.
0xd34df00d
11.06.2015 18:55Я только не понял, зачем у самого
object
егоis_null()
иto_string()
— виртуальные. Аналогично у его наследников.
StrangerInRed
Конечно же можно писать с динамикой типов на С++, но почему бы не взять тот же obj-c, или vala наконец? Еще можно менее кросплатформенные — swift, C# и т.д.
stepanp
Swift офигенно кроссплатформенный