Худшие места в C++ для написания кода

Kate

Administrator
Команда форума
Любой язык программирования, а особенно 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
Стоит заметить, что этот код может казаться переусложненным из-за того, что тут во-первых, не используются возможности 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);
};
Эти и другие проблемы конструкторов можно прочитать в статье

Отдельная боль случается, когда приходится писать код не просто в конструкторе, а в 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", который будет управлять временем жизни транзакции. Какой код следует поместить в деструктор - код, который закоммитит созданную транзакцию или код, который отменит ее?

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

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

Виртуальные (но не чисто виртуальные) методы​

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


Источник статьи: https://habr.com/ru/post/555664/
 
Сверху