Любой язык программирования, а особенно C++, предоставляет всевозможные средства для написания кода. В начале карьеры любому программисту кажется, что для того, чтобы мастерски овладеть языком, надо использовать по максимуму его возможностей. Но так ли это? Может, лучше наоборот, ограничиться необходимым минимумом средств, и не использовать сложные, подверженные ошибками конструкции? Давайте посмотрим на те возможности C++, где код нужно стараться не писать вовсе.
Шаблоны
Думаю, каждый разработчик на C++ сталкивался с нечитаемой простыней шаблонного кода. Шаблоны сложно писать, ошибки, возникающие при провале компиляции шаблонов, разрастаются на десятки экранов, а стоит отвлечься хотя бы на неделю от поддержки шаблонного куска кода, как вспомнить хотя бы примерно, что там происходит, за разумное время практически невозможно.
Вместе с этим, можно вспомнить, что все распространенные промышленные языки программирования, в том числе C++, являются тьюринг-полными, что означает, что на любом языке можно реализовать любую программу. Зачем же тогда нужны шаблоны?
Мой ответ такой, что шаблоны нужны для упрощения работы программиста, взаимодействующего с вашим кодом. То есть у программиста должен быть вариант заиспользовать ваш код без влезания в шаблонные дебри, возможно с небольшими правками (здесь идет речь о поддержке кода внутри одного проекта, с библиотечным кодом ситуация может быть сложнее). Причем у программиста должна быть возможность легко избавиться от шаблонов как в одном определенном месте, так и во всем проекте целиком. Таким образом, если шаблонный код действительно дает какие-то преимущества над нешаблонным, пользователь выберет именно его, и в любой момент он сможет избавиться от шаблонов, оставив основную функциональность без изменений.
Например, однажды мне был нужен класс, похожий на std::tuple, для того чтобы разные компоненты программы могли обмениваться экземплярами класса друг с другом. Например, компоненты c1 и c2 создают набор элементов A1, A2, A3 и B1, B2, B3 соответственно, а функции f1 и f2 их потребляют
void f1(const tuple<A1, A2, A3>& t);
void f2(const tuple<B1, B2, B3>& t1, const tuple<A1, A2, A3>& t2);
void main() {
//...
tuple<A1, A2, A3> t1 = c1.get();
tuple<B1, B2, B3> t2 = c2.get();
f1(t1);
f2(t2, t1);
}
Но, как правило, потребляющим функциям бывают нужны не все элементы, которые производят компоненты. Например, функции f2 могут быть нужны элементы A1 и A3, и не нужен элемент A2. Зачем же тогда его указывать в заголовке функции?
Давайте в таком случае создадим обертку над классом tuple, которая будет "отсекать" лишние элементы
Tuple
// Не стоит бездумно использовать этот класс, так как он проектировался с учетом локальной специфики
template<typename T0, typename ...T>
class Tuple {
private:
template<typename Arg>
struct ChooseTagType{
using type = typename std::conditional<IsTuple<Arg>(), TupleChoiseTag, typename std::conditional<IsPair<Arg>(), PairChoiseTag, OtherChoiseTag>::type>::type;
};
template<typename T1, typename Arg>
static T1 getImpl(OtherChoiseTag, Arg &&arg) {
return arg;
}
template<typename T1, typename ...Args>
static T1 getImpl(PairChoiseTag, const std::pair<Args...> &arg) {
return std::get<T1>(arg);
}
template<typename T1, typename ...Args>
static T1 getImpl(TupleChoiseTag, const std::tuple<Args...> &arg) {
return std::get<T1>(arg);
}
template<typename T1, typename Arg>
static T1 get(Arg &&arg) {
const typename ChooseTagType<Arg>::type t;
return getImpl<T1>(t, std::forward<Arg>(arg));
}
template<typename StdTuple, typename M>
static auto makeLambda(StdTuple stdTuple) {
return [stdTuple]() -> M& {
return *get<M*>(stdTuple);
};
}
public:
template<typename StdTuple>
Tuple(StdTuple stdTuple) {
getter = std::make_tuple(
makeLambda<StdTuple, T0>(stdTuple),
makeLambda<StdTuple, T>(stdTuple)...
);
}
template<bool B = true, typename Dummy = typename std::enable_if<sizeof...(T) == 0 && B>::type>
T0& get() {
return std::get<0>(getter)();
}
template<typename M>
M& get() {
return std::get<std::function<M&()>>(getter)();
}
private:
std::tuple<std::function<T0&()>, std::function<T&()>...> getter;
};
Стоит заметить, что этот код может казаться переусложненным из-за того, что тут во-первых, не используются возможности C++17, а во-вторых, этот код был создан с оглядкой на некоторую специфику использования, которая из данного примера была вырезана. Возможно, без указанной специфики, если написать его с нуля, данный код получился бы много проще, но пытаться упростить его у меня нет желания.
Использование этого класса будет выглядеть следующим образом
void f1(const Tuple<A1, A2, A3>& t);
void f2(const Tuple<B1, B2, B3>& t1, const Tuple<A1, A3>& t2);
void main() {
//...
tuple<A1, A2, A3> t1 = c1.get();
tuple<B1, B2, B3> t2 = c2.get();
f1(t1);
f2(t2, t1);
}
В решаемой задаче предполагается, что классы элементы классов A*, B* будут уникальны во всех tuple, что значит, что например tuple<B1, B1, B2> нам никогда не встретится. Но что, если требования все же изменятся, и такое станет возможно? Или что, если в реализации класса Tuple есть неочевидная ошибка? Что если данный код откажется компилироваться под каким-то компилятором? В таких случаях всегда можно отказаться от использования данного класса, как в одном конкретном месте, так и во всех местах сразу, просто чуть подправив код. Таким образом, мы не завязываем пользователя на использование нашего класса, если наш класс действительно дает какие-то реальные преимущества, пользователь им воспользуется.
Конструктор
Казалось бы, конструктор - неотъемлемый аттрибут C++. Что с ним может быть не так? Да много чего.
Из конструктора нельзя вернуть результат его выполнения, кроме как выбросив исключение. Но при этом, выбросив из конструктора исключения, вы должны помнить, что деструктор данного элемента вызван не будет.
Также конструктору нельзя дать хорошее описательное имя, он же конструктор. Например, мы хотим распарсить какой-то класс из json
class A {
public:
A(const string& json);
};
Но что, если в дальнейшем нам потребуется распарсить не только json, но и xml? Как решить данную задачу? Передавать дополнительный булев параметр isJson?
class A {
public:
A(const string& jsonOrXml, bool isJson);
};
Эти и другие проблемы конструкторов можно прочитать в статье
https://habr.com/ru/post/460831/
Отдельная боль случается, когда приходится писать код не просто в конструкторе, а в member initializer list конструктора. Этого можно добиться путем добавления модификатора const к членам класса и некоторыми другими способами. Чем плох initializer list конструктора, пояснять, надеюсь не нужно: помимо стандартных проблем с конструктором здесь возникает множество других проблем, таких как ужасный синтаксис перехвата исключений, зависимость порядка инициализации от порядка объявления полей в классе, невозможность доинициализировать поле позже в процессе инициализации.
Что делать? В решении этой проблемы помогут 2 подхода: фабричная функция (не путать с фабричным методом) и dependency injection.
Например, создать экземпляр класса из json можно так
class A {
public:
A(int i, const string& s)
: i(i)
, s(s)
{}
public:
static optinal<A> fromJson(const string& s) {
// parse s
if (!parsed) {
return nullopt;
}
return A(iField, sField);
}
static optional<A> fromXml(const string& s) {
// parse s
if (!parsed) {
return nullopt;
}
return A(iField, sField);
}
private:
int i;
string s;
};
Тут у нас уже нет никаких препятствий ни в придумывании выразительного имени функции, ни в возврате удобного нам значения, ни других ограничений.
В случае же конструирования комплексных классов, состоящих из сложных классов-полей, нужно воспользоваться паттерном "Внедрение зависимости".
Деструктор
Еще более сложный и неустойчивый элемент по сравнению с конструктором, это конечно же, деструктор. Вдобавок к недостаткам, присущим конструктору, деструктор имеет и свои собственные.
Во-первых, из деструктора нельзя вернуть результат выполнения никаким образом, даже через исключение.
Во-вторых, деструктор обладает такой неприятной особенностью, как невозможность отследить все места в коде, из которого он будет вызван. В случае с обычным методом это сделать довольно просто - можно пройтись поиском по всему проекту и увидеть все вызовы. С деструктором же так не пройдет. В результате этого постоянно возникают ситуации, когда деструктор вызывается не из того места в коде, где предполагалось, и даже не обязательно из предполагаемого потока, также часто бывает, когда объект "застревает" где-нибудь в кэше, из-за чего вызова деструктора можно не дождаться вовсе.
Какое решение данной проблемы? К сожалению, избавиться от кода в деструкторе не так просто, как от кода в конструкторах. В каждом конкретном случае придется действовать уникальным образом. Но можно дать несколько советов.
Во-первых, следует разделять RAII и не-RAII деструкторы. Некоторые программисты ошибочно полагают, что если они написали деструктор, то они реализовали идиому RAII. Но это не так. RAII - это о захвате/освобождении ресурса, и только. Захватили ресурс "память" - отдали память через деструктор. Захватили ресурс "файловый дескриптор" - отдали его через деструктор. Причем отдаем мы дескриптор лишь с одной целью - чтобы в последствии мы могли захватить этот дескриптор вновь (ну и уменьшить счетчик активных дескрипторов тоже). Ни о каком сбросе буфера на диск здесь речи идти не будет.
Что же делать с деструкторами, которые не являются RAII? Здесь можно обратиться к языку rust и увидеть, что в нем нету требования на обязательный вызов деструктора. Деструктор может как вызываться при выходе из скоупа, так и не вызваться, если мы его вызов где-то раньше отменили. Я предлагаю действовать похожим образом. Давайте будем считать, что деструктор в C++ может не выполниться вовсе или выполниться только частично, и строить логику программы исходя из этого.
Например, мы хотим создать класс "BdTransaction", который будет управлять временем жизни транзакции. Какой код следует поместить в деструктор - код, который закоммитит созданную транзакцию или код, который отменит ее?
Код, который закоммитит транзакцию, включать в деструктор не стоит, так как в этом случае коммит транзакции будет происходить всегда, даже в случае выброса исключения в пользовательском коде, приводя данные в транзакции в несогласованное состояние. Но нас в данном случае интересует даже не это. Что, если в процессе попытки создания транзакции связь с базой данных прервется? Мы можем попробовать сделать несколько попыток восстановить связь, но эти попытки нам ничего не гарантируют. Таким образом, мы не можем даже сообщить никуда извне о нашем неуспехе, из-за чего пользователь, смотрящий на наш класс, будет считать, что подтвержение транзакции произойдет в любом случае, что очевидно не так.
Что же, если мы поместим в деструктор код, отменяющий транзакцию? Во-первых, это более логично с точки зрения пользователя кода, так как если произойдет исключение или ранний выход из функции, несогласованные данные не достигнут базы. Но нам сейчас важнее то, что даже если нам не удастся в деструкторе отменить транзакцию из-за потери связи с бд, то база данных отменит нашу транзакцию со временем самостоятельно, тем самым контракт класса действительно дает нам гарантию того, что транзакция будет отменена.
Виртуальные (но не чисто виртуальные) методы
Последнее место, которое хочется упомянуть - это иерархия наследования, виртуальных методов и т.п. Сложные иерархии классов с частично переопределенными виртуальными функциями - это боль для программиста, работающего с данным кодом. Рецепт избавления от данных проблем давно известен - предпочитайте композицию наследованию, делайте чисто виртуальные методы.
dvserg
А вариант использовать другой класс не рассматривается? Либо делать два разных метода в классе?
class A {
public:
A();
void ImportJson(const string& json);
void ImportXML(const string& xml);
};
svr_91 Автор
Но результатом должна быть одна структура/класс. То есть мы должны получить из разных форматов одну структуру
По сути я это и предлагаю. Вместо конструкторов делать функции/статические методы
dvserg
Но тогда это уже не проблема конструкторов/деструкторов, а проблема стиля программирования. Делать из них комбайны плохой стиль. Чем они проще, тем меньше граблей. Полу-созданные/полу-удаленные объекты могут приподнести много сюрпризов, и некоторые об этом забывают.
svr_91 Автор
В том то и дело, что не нужно делать полу-созданные объекты. Функция должна возрващать целиком сконструированный объект
anonymous
Конструктор должен создавать объект, а не парсить/вытягивать из бд/реализовывать какой-либо ещё функционал. Если нужно произвести подготовку данных — это должно быть сделано до вызова конструктора.
svr_91 Автор
Да, про это и есть моя статья
qw1
Я считаю, не нужно помещать в конструктор код, который может сбойнуть от входных данных. Например, что будет, если передать невалидный xml/json? По идее, конструктор должен выкидывать исключение, но исключения в конструкторе — плохая идея, потому что там есть свои подводные камни.
Если же инициализация объекта xml/json-данными будет отдельной ф-цией, эта ф-ция может и статус возвращать, и принимать дополнительные параметры.
svr_91 Автор
Да, но именно про это и была моя статья. Очень странное чувство, что хабр мне показывает какую-то другую статью, в отличие от остальных читателей :) Но видимо такая уж дурацкая у меня форма подачи, что не смог выделить ключевые моменты
GavriKos
Можно тайпдефы использовать чтобы разделить какой тип мы передаем в конструктор — джсон или хмл.
Можно передавать не строку а контейнер.
Можно все разнести по разным классам с общей базой.
И еще пачка архитектурных решений.
А вот статические методы — зло.
svr_91 Автор
Одними typedef-ами тут не обойтись.
using Json = string
using Xml = string
это все один и тот же string
Можно, но зачастую слишком муторно пилить по контейнеру на каждый чих, чтобы воспользоваться ими всего в одном месте
По мне довольно плохое решение
А почему они зло?
GavriKos
Муторно флаг делать, а потом вспоминать что он там значит ))) А делать декомпозицию — правильно. Если в ней есть необходимость.
Статические методы — зло — потому что вносят хаос и беспорядок. А в вашем случае вообще не решают проблемы.
enree
Есть strong typedef.
svr_91 Автор
Да, но для них нужно отдельную библиотеку подключать
0xd34df00d
Можно сделать tag dispatch-подобные вещи.
csl
Взывая к более типизируемым языкам… В хаскеле есть именованный конструктор типа. А в Идрисе 1, Идрисе 2, Агде?
0xd34df00d
Да, есть.
Только там в конструкторах нет никакой логики, это в каком-то смысле просто метки.
FDA847
В Delphi можно создавать несколько конструкторов и вызывать их напрямую.
qw1
svr_91 Автор
Конкретно в данном случае это особенность представления данных, если нам нужно вернуть ошибку, от этого никуда не деться. Да и в этом случае возможно будет RVO или что-то вроде этого, а если делать без Optional, то RVO будет точно.
В любом случае, заботиться о скорости работы именно на этом этапе разработки программы бесмыссленно. В том смысле, что лучше убирать «бутылочные горлышки» точечно, а не жертвуя выразительностью ради производительности
Tujh
enree
Если у нас вдруг добавится парсинг YAML? Тоже добавлять метод в класс? В идеале, конечно, иметь какую нибудь рефлексию и стандартные (де)сериализаторы в разные форматы. Но загрязнять класс парсингом из произвольных форматов, имхо, так себе вариант.
TargetSan
Для этого процесс сериализации отделяют от самого сериализуемого объекта. Один из примеров — библиотека Cereal.
chry
А вариант использования Command отбрасывается?
A(const std::string& s, AbstractParser&), к примеру?
А если использовать специфики С++ с так Вами ругаемыми шаблонами, то можно получить ещё более выразительный код, который, к тому же, будет эффективнее.
template<typename Parser> A(const std::string& s);
svr_91 Автор
Можно и так, но не всегда это требуется. Обычно бывает так, что класс все время десерилизуется из json. И для этого вводить этот паттерн не требуется. А потом появляется второй формат…
Плюс, как я помню этот подход, там все равно используется фабричный метод, а не конструктор
chry
В общем случае идея одна: грамотное проектирование — решение всх проблем, поэтому статья мне кажется неуместной и откровенно глупой, Вы уж простите. Все проблемы, которые вы перечислили не проблемы вовсе, если заранее думать как писать и использовать инструменты там, где их нужно использовать.
В смысле деструктор не вызовется? Как это? https://isocpp.org/wiki/faq/exceptions#ctor-exceptions, да и вообще, Вам, наверное, стоит почитать про RAII.
Ну тут я вообще не понял, в чем проблема-то? Не используйте их, если в данном месте они не нужны. С таким же успехом можно нагнать на что угодно, вот смотрите: "макросы нечитаемый отстой", попробуйте доказать, что это не так. Да и вообще, вы знаете инструмент удобнее, чем шаблоны для генерации кода? Я — нет. Потому что других механизмов нет, и это, отнюдь, не боль для программиста, если программист знат где их использовать, а главное умеет это делать.
Соглашусь с нечитаемостью, да, синтаксис морально устаревает и с развитием языка становится всё более громоздким, но это всё не бОльшая претензия, чем говорить, что в Java очень длинные имена классов.
Кроме того, есть такая замечательная штука как концепты в С++20, если вас не устраивают шаблоны, посмотрите на них.
Вот этот отрывок информации меня просто сразил. То есть Вы говорите, что вот, у нас в C++ должны быть только интерфейсы, что мы против полиморфного поведения, мы отрицаем добрую часть работы "Банды четырех?", все механизмы vtable — отстой, а stl с их basic_classname неправы и добавляют боли программистам (сюда же и поголовные шаблоны в stl)? Какая глупость.
Резюмирую: мне данная статья кажется некомпетентной. Всё Вами перечисленное — проблемы проектирования и неумения применять инструменты языка.
enree
Вообще, мне кажется, что если есть какая-то вероятность десериализовать из нескольких разных форматах, то хорошо использовать какое-то промежуточное представление дерева, в который парсить и из которого разбирать.
Так получается, что есть у нас десятка три класса, умеющих разбираться из json. Светлым умам пришла идея читать из XML. В три десятка классов добавляем чтение? А потом плюс класс, добавляем в него две десериализации. Звучит грустно, если только вы не на почасовой оплате.
Но если очень надо всё делать в классе, то можно, наверное, так:
Зачем обязательно передавать строку?