…
Часть 4. Классы
…
Эта статья является переводом части руководства Google по стилю в C++ на русский язык.
Исходная статья (fork на github), обновляемый перевод.
Классы
Классы являются основным строительным блоком в C++. И, конечно же, используются они часто. В этой секции описаны основные правила и запреты, которым нужно следовать при использовании классов.
Код в конструкторе
Не вызывайте виртуальные методы в конструкторе. Избегайте инициализации, которая может завершиться ошибкой (а способа сигнализировать об ошибке не предусмотрено. Прим.: учтите, что Гугл не любит исключения).
Определение
Вообще в конструкторе можно выполнять любые инициализации (т.е. всю инициализацию сделать в конструкторе).
За
- Не нужно беспокоиться об неинициализированном классе.
- Объекты, которые полностью инициализируются в конструкторе, могут быть константными (const) и также их легче использовать в стандартных контейнерах и алгоритмах.
Против
- Если в конструкторе вызываются виртуальные функции, то не вызываются реализации из производного класса. Даже если сейчас класс не имеет потомков, в будущем это может обернуться проблемой.
- Нет простого способа проинформировать об ошибке без крэша программы (что не всегда допустимо) или выбрасывания исключений (которые запрещены).
- Если возникла ошибка, то у нас есть частично (обычно — неправильно) инициализированный объект. Очевидное действие: добавить механизм проверки состояния bool IsValid(). Однако про эту проверку легко забыть.
- Вы не можете пользоваться адресом конструктора. Поэтому нет, например, простого способа передать выполнение конструктора в другой поток.
Вердикт
Конструкторы не должны вызывать виртуальные функции. В ряде случаев (если это позволительно) обработка ошибок конструирования возможна через завершение программы. В иных случаях рассмотрите паттерн Фабричный Метод или используйте Init() (подробнее здесь:TotW #42). Используйте Init() только в случае, если у объекта есть флаги состояния, разрешающие вызывать те или иные публичные функции (т.к. сложно полноценно работать с частично сконструированным объектом).
Неявные преобразования
Не объявляейте неявные преобразования. Используйте ключевое слово explicit для операторов преобразования типа и конструкторов с одним аргументом.
Определение
Неявные преобразования позволяют объект одного типа (source type) использовать там, где ожидается другой тип (destinationtype), например передача аргумента типа int в функцию, ожидающую double.
Помимо неявных преобразований, задаваемых языком программирования, можно также определять свои пользовательские, добавляя соответствующие члены в объявление класса (как источника, так и получателя). Неявное преобразование на стороне источника объявляется как оператор + тип получателя (например, operator bool()). Неявное преобразование на стороне получателя реализуется конструктором, принимающим тип источника как единственный аргумент (помимо аргументов со значениями по умолчанию).
Ключевое слово explicit может применяться к конструктору или к оператору преобразования для явного указания, что функция может применяться только при явном соответствии типов (например, после операции приведения). Это применяется не только для неявного преобразования, но и для списков инициализации в C++11:
class Foo { explicit Foo(int x, double y); ... }; void Func(Foo f);
Func({42, 3.14}); // Ошибка
Этот пример кода технически не является неявным преобразованием, но язык трактует это как будто подразумевается explicit.
За
- Неявные преобразования могут сделать тип более удобным в использовании, не требуя явного указания типа в очевидных случаях.
- Неявные преобразования могут быть упрощённой альтернативой для перегрузки, например когда одна функция с аргументом типа string_view применяется вместо отдельных версий для std::string и const char*.
- Применение списка инициализации является компактным и понятным способом инициализации объектов.
Против
- Неявные преобразования могут скрывать баги с несоответствием типов, когда получаемый тип не соответствует ожиданиям пользователя (если он вообще предополагал приведение типа).
- Неявные преобразования могут усложнить чтение кода, особенно при наличии перегруженных функций: становится неясно, какой код действительно будет вызван.
- Конструкторы с одним аргументом могут быть случайно использованы как неявное преобразование, даже если это не предполагалось.
- Когда конструктор с одним аргументом не объявлен как explicit нельзя с уверенностью сказать: то ли это такое неявное преобразование, то ли автор забыл ключевое слово.
- Не всегда понятно, какой тип должен обеспечить преобразование. А если — оба, код становится двусмысленным.
- Использование списка инициализации также может добавить проблем, если целевой тип задан неявно и, особенно, если сам список состоит только из одного элемента.
Вердикт
Операторы преобразования типа и конструкторы с одним аргументомдолжны объявляться с ключевым словом explicit. Есть и исключение: конструкторы копирования и перемещения могут объявляться без explicit, т.к. они не выполняют преобразование типов. Ещё неявные преобразования могут бытьнеобходимы в случае классов-обёрток для других типов (в этом случае обязательно запросите разрешение у вышестоящего руководства на возможность игнорирования этого важного правила).
Конструкторы, которые нельзя вызвать с одним аргументом, можно объявлять безexplicit. Конструкторы, принимающие единственный std::initializer_list также должны объявляться без explicit для поддержки инициализации копированием (например, MyType m = {1, 2};).
Копируемые и перемещаемые типы
Открытый интерфейс класса должен явно указывать на возможность копирования и/или перемещения, или наоборот всё запрещать. Поддерживайте копирование и/или перемещение, только если эти операции имеют смысл для вашего типа.
Определение
Перемещаемый тип — тот, что может быть инициализирован или присвоен из временных значений.
Копируемый тип — может быть инициализирован или присвоен из другого объекта того же типа (т.е. также, как и перемещаемый), с условием, что исходный объект остаётся неизменным.Например, std::unique_ptr<int> — это перемещаемый, но не копируемый тип(т.к. значение исходного std::unique_ptr<int> объекта должно измениться при присвоении целевому объекту). int и std::string — примерыперемещаемый типов, которые также можно копировать: для int операции перемещения и копирования одинаковые, для std::string операция перемещения требует меньше ресурсов, чем копирование.
Для пользовательских типов копирование задаётся конструктором копирования и оператором копирования.Перемещение задаётся либо конструктором перемещения с оператором перемещения, либо (если их нет)соответствующими функциями копирования.
Конструкторы копирования и перемещения могут неявно вызываться компилятором, например при передаче объектов по значению.
За
Объекты копируемых и перемещаемых типов могут быть переданы и получены по значению, что делает API проще, безопаснее, универсальнее. В этом случает нет проблем с владением объекта, его жизненным циклом, изменением значения и т.п., а также не требуется указывать их в «контракте» (всё это в отличие от передачи объектов по указателю или ссылке). Также предотвращается отложенное взаимодействие между клиентом и реализацией, что существенно облегчает понимание и поддержку кода, а также его оптимизацию компилятором. Такие объекты могут использоваться как аргументы других классов, требующих передачу по значению, (например, большинство контейнеров), и вообще они гибче (например, при использовании в паттернах проектирования).
Конструкторы копирования/перемещения и соответствующие операторы присваивания обычно легче определить, чем альтернативы наподобие Clone(), CopyFrom() или Swap(), т.к. компилятор может сгенерировать требуемые функции (неявно или посредством = default). Они (функции) легко объявляются и можно быть уверенным, что все члены класса будут скопированы. Конструкторы (копирования и перемещения) в целом более эффективны, т.к. не требуют выделения памяти, отдельной инициализации, дополнительных присвоений, хорошо оптимизируются (см. copy elision).
Операторы перемещения позволяют эффективно (и неявно) управлять ресурсами rvalue объектов. Иногда это упрощает кодирование.
Против
Некоторым типам не требуется быть копируемыми, и поддержка операций копирования может противоречить логике или привести к некорректной работе. Типы для синглтонов (Registerer), объекты для очистки (например, при выходе за область видимости) (Cleanup) или содержащие уникальные данные (Mutex) по своему смыслу являются некопируемыми. Также, операции копирования для базовых классов, имеющих наследников, могут могут привести к «нарезке объекта» object slicing. Операции копирования по умолчанию (или неаккуратно написаные) могут привести ошибкам, которые тяжело обнаружить.
Конструкторы копирования вызываются неявно и это легко упустить из виду (особенно для программистов, которые раньше писали на языках, где передача объектов производится по ссылке). Также можно снизить производительность, делая лишние копирования.
Вердикт
Открытый интерфейс каждого класса должен явно указывать, какие операции копирования и/или перемещения он поддерживает. Обычно это делается в секции public в виде явных деклараций нужных функций или объявлением их как delete.
В частности, копирумый класс должен явно объявлять операции копирования; только перемещаемый класс должен явно объявить операции перемещения; некопируемый/неперемещаемый класс должен явно запретить (= delete) операции копирования. Явная декларация или удаление всех четырёх функций копирования и перемещения также допустима, хотя это и не требуется. Если вы реализуете оператор копирования и/или перемещения, то необходимо также сделать соответствующий конструктор.
class Copyable { public: Copyable(const Copyable& other) = default; Copyable& operator=(const Copyable& other) = default; // Неявное определение операций перемещения будет запрещено (т.к. объявлено копирование) }; class MoveOnly { public: MoveOnly(MoveOnly&& other); MoveOnly& operator=(MoveOnly&& other); // Неявно определённые операции копирования удаляются. Но (если хотите) можно это записать явно: MoveOnly(const MoveOnly&) = delete; MoveOnly& operator=(const MoveOnly&) = delete; }; class NotCopyableOrMovable { public: // Такое объявление запрещает и копирование и перемещение NotCopyableOrMovable(const NotCopyableOrMovable&) = delete; NotCopyableOrMovable& operator=(const NotCopyableOrMovable&) = delete; // Хотя операции перемещения запрещены (неявно), можно записать это явно: NotCopyableOrMovable(NotCopyableOrMovable&&) = delete; NotCopyableOrMovable& operator=(NotCopyableOrMovable&&) = delete; };
Описываемые объявления или удаления функций можно опустить в очевидных случаях:
- Если класс не содержит секции private (например, структура struct или класс-интерфейс), то копируемость и перемещаемость можно заявить через аналогичное свойство любого открытого члена.
- Если базовый класс явно некопируемый и неперемещаемый, наследные классы будут такими же. Однако, если базовый класс не объявляет это операции, то этого будет недостаточно для прояснения свойств наследуемых классов.
- Заметим, что если (например) конструктор копирования объявлен/удалён, то нужно и явно объявить/удалить оператор копирования (т.к. его статус неочевиден). Аналогично и для объявления/удаления оператора копирования. Аналогично и для операций перемещения.
Тип не следует объявлять копируемым/перемещаемым, если для обычного программиста не понятна необходимость этих операций или если операции очень требовательны к ресурсам и производительности. Операции перемещения для копируемых типов это всегда оптимизация производительности, но с другой стороны — это потенциальный источник багов и усложнений. Поэтому не объявляйте операции перемещения, если они не дают значительного выигрыша по производительности по сравнению с копированием. Вообще желательно (если для класса заявляются операции копирования) всё спроектировать так, чтобы использовались функции копирования по-умолчанию. И обязательно проверьте корректность работы любых операций по-умолчанию.
Из-за риска «слайсинга» предпочтительным будет избегать открытых операторов копирования и перемещения для классов, которые планируется использовать в качестве базовых (и предпочтительно не наследоваться от класса с такими функциями). Если же необходимо сделать базовый класс копируемым, то сделайте открытую виртуальную функцию Clone() и защищённый (protected) конструктор копий с тем, чтобы производный класс мог их использовать для реализации операций копирования.
Структуры vs Классы
Используйте структуры (struct) только для пассивных объектов, хранящих данные. В других случаях используйте классы (class).
Ключевые слова struct и class практически идентичны в C++. Однако, у нас есть собственное понимание для каждого ключевого слова, поэтому используйте то, которое подходит по назначению и смыслу.
Структуры должны использоваться для пассивных объектов, только для переноса данных. Они могут иметь собственные константы, однако не должно быть никакой функциональности (за, возможно, исключением функций get/set). Все поля должны быть открытыми (public), доступны для прямого доступа и это более предпочтительно, чем использование функций get/set. Структуры не должны содержать инварианты (например, вычисленные значения), которые основаны на зависимости между различными полями структуры: возможность напрямую изменять поля может сделать инвариант невалидным. Методы не должны ограничивать использование структуры, но могут присваивать значения полям: т.е. как конструктор, деструктор или функции Initialize(), Reset().
Если требуется дополнительная функциональность в обработке данных или инварианты, то предпочтительно применение классов (class). Также, если сомневаетесь, что выбрать — используйте классы.
В ряде случаев (шаблонные мета-функции, traits, некоторые функторы) для единообразия с STL допускается использование структур вместо классов.
Не забудьте, что переменные в структурах и классах именуются разными стилями.
Структуры vs пары (pair) и кортежи (tuple)
Если отдельные элементы в блоке данных могут осмысленно называться, то желательно использовать структуры вместо пар или кортежей.
Хотя использование пар и кортежей позволяет не изобретать велосипед с собственным типом и сэкономит много времени при написании кода, поля с осмысленными именами (вместо .first, .second или std::get<X>) будут более понятны при чтении кода. И хотя C++14 для кортежей в дополнение к доступу по индексу добавляется доступ по типу (std::get<Type>, а тип должен быть уникальным), имя поля намного более информативно нежели тип.
Пары и кортежи являются подходящими в коде, где нет специального различия между элементами пары или кортежа. Также они требуются для работы с существующим кодом или API.
Наследование
Часто композиция класса более подходяща, чем наследование. Когда используйте наследование, делайте его открытым (public).
Определение
Когда дочерний класс наследуется от базового, он включает определения всех данных и операций от базового. «Наследование интерфейса» — это наследование от чистого абстрактного базового класса (в нём не определены состояние или методы). Всё остальное — это «наследование реализации».
За
Наследование реализации уменьшает размер кода благодаря повторному использованию частей базового класса (который становится частью нового класса). Т.к. наследование является декларацией времени компиляции, это позволяет компилятору понимать структуру и находить ошибки. Наследование интерфейса может быть использовано чтобы класс поддерживал требуемый API. И также, компилятор может находить ошибки, если класс не определяет требуемый метод наследуемого API.
Против
В случае наследования реализации, код начинает размазываться между базовым и дочерним классом и это может усложнить понимание кода. Также, дочерний класс не может переопределять код невиртуальных функций (не может менять их реализацию).
Множественное наследование ещё более проблемное, а также иногда приводит к уменьшению производительности. Часто просадка производительности при переходе от одиночного наследования к множественному может быть больше, чем переход от обычных функций к виртуальным. Также от множественного наследования один шаг до ромбического, а это уже ведёт к неопределённости, путанице и, конечно же, багам.
Вердикт
Любое наследование должно быть открытым (public). Если хочется сделать закрытое (private), то лучше добавить новый член с экземпляром базового класса.
Не злоупотребляйте наследованием реализации. Композиция классов часто более предпочтительна. Попытайтесь ограничить использование наследования семантикой «Является»: Bar можно наследовать от Foo, если можно сказать, что Bar «Является» Foo (т.е. там, где используется Foo, можно также использовать и Bar).
Защищёнными (protected) делайте лишь те функции, которые должны быть доступны для дочерних классов. Заметьте, что данные должны быть закрытыми (private).
Явно декларируйте переопределение виртуальных функций/деструктора с помошью спецификаторов: либо override, либо (если требуется) final. Не используйте спецификатор virtual при переопределении функций. Объяснение: функция или деструктор, помеченные override или final, но не являющиеся виртуальными просто не скомпилируются (что помогает обнаружить общие ошибки). Также спецификаторы работают как документация; а если спецификаторов нет, то программист будет вынужден проверить всю иерархию, чтобы уточнить виртуальность функции.
Множественное наследование допустимо, однако множественное наследование реализации не рекомендуется от слова совсем.
Перегрузка операторов
Перегружайте операторы в рамках разумного. Не используйте пользовательские литералы.
Определение
C++ позволяет пользовательскому коду переопределять встроенные операторы используя ключевое слово operator и пользовательский типа как один из параметров; также operator позволяет определять новые литералы, используя operator""; также можно создавать функции приведения типов, наподобие operator bool().
За
Использование перегрузки операторов для пользовательских типов (по аналогии со встроенными типами) может сделать код более сжатым и интуитивным. Перегружаемые операторы соответствуют определённым операциям (например, ==, <, = и <<) и если код следует логике применения этих операций, то пользовательские типы можно сделать понятнее и использовать при работе с внешними библиотеками, которые опираются на эти операции.
Пользовательские литералы — очень эффективный способ для создания пользовательских объектов.
Против
- Написать комплект операторов для класса (корректных, согласованных и логичных) — это может потребовать известных усилий и, при недоработанном коде, это может обернуться труднопонимаемыми багами.
- Излишняя перегрузка операторов может усложнить понимание кода, особенно если код не соответствует логике операции.
- Все недостатки, связанные с перегрузкой функций, присущи и перегрузке операторов.
- Перегрузка операторов обмануть других программистов, ожидающих простую и быструю встроенную операцию, а получающие нечто ресурсоёмкое.
- Поиск мест вызова перегруженных операторов может быть нетривиальной задачей, и это явно сложнее обычного текстового поиска.
- При ошибках в типах аргументов вы можете вместо сообщений об ошибке/предупреждении от компилятора (по которым легко найти проблему и исправить её), получить «корректный» вызов другого оператора. Например, код для foo < bar может сильно отличаться от кода для &foo < &bar; немного напутав в типах получим ошибочный вызов.
- Перегрузка некоторых операторов является в принципе рискованным занятием. Перегрузка унарного & может привести к тому, что один и тот же код будет трактоваться по разному в зависимости от видимости декларации этой перегрузки. Перегрузка операторов &&, || и , (запятая) может поменять порядок (и правила) вычисления выражений.
- Часто операторы определяются вне класса, и есть риск использования разных реализаций одного и того же оператора. Если оба определения будут слинкованы в один бинарный файл, можно получить неопределённое поведение и хитрые баги.
- Пользовательские литералы (UDL) позволяют создавать новые синтаксические формы, незнакомые даже продвинутым C++ программистам. Например: «Hello World»sv как сокращение для std::string_view(«Hello World»). Исходная нотация может быть более понятной, хотя и не такой компактной.
- Т.к. для UDL не указывается пространство имён, потребуется либо использовать using-директиву (которая запрещена) или using-декларацию (которая также запрещена (в заголовочных файлах), кроме случая когда импортируемые имена являются частью интерфейса, показываемого в заголовочном файле). Для таких заголовочных файлов лучше бы избегать суффикусов UDL, и желательно избегать зависимости между литералами, которые различны в заголовочном и исходном файле.
Вердикт
Определяйте перегруженные операторы только если их смысл очевиден, понятен, и соответствует общей логике. Например, используйте | в смысле операции ИЛИ; реализовывать же вместо этого логику канала (pipe) — не очень хорошая идея.
Определяйте операторы только для ваших собственных типов, делайте это в том же самом заголовочном и исходном файле, и в том же пространстве имён. В результате операторы будут доступны там же, где и сами типы, а риск множественного определения минимален. По возможности избегайте определения операторов как шаблонов, т.к. придётся соответствовать любому набору шаблонных аргументов. Если вы определяете оператор, также определяйте «родственные» к нему. И позаботьтесь о согласованности выдаваемых ими результатов. Например, если определяется оператор <, то определяйте все операторы сравнения и проследите, чтобы операторы < и > никогда не возвращали true для один и тех же аргументов.
Желательно определять неизменяющие значения бинарные операторы как внешние функции (не-члены). Если же бинарный оператор объявлен членом класса, неявное преобразование может применяться к правому аргументу, но не к левому. А это может слегка расстроить программистов, если (например) код a < b — будет компилироваться, а b < a — нет.
Не нужно пытаться обойти переопределение операторов. Если требуется сравнение (или присваивание и функция вывода), то лучше определить == (или = и <<) вместо своих функций Equals(), CopyFrom() и PrintTo(). И наоборот: не нужно переопределять оператор только потому, что внешние библиотеки ожидают этого. Например, если тип данных нельзя упорядочить и хочется хранить его в std::set, то лучше сделайте пользовательскую функцию сравнения и не пользуйтесь оператором <.
Не переопределяйте &&, ||, , (запятая) или унарный &. Не переопределяйте operator"", т.е. не стоит вводить собственные литералы. Не используйте ранее определённые литералы (включая стандартную библиотеку и не только).
Дополнительная информация:
Преобразование типов описано в секции неявные преобразования. Оператор = расписан в конструкторе копий. Тема перегрузки << для работы со стримами освещена в потоках/streams. Также можно ознакомиться с правилами из раздела перегрузка функций, которые также подходят и для операторов.
Доступ к членам класса
Данные класса делайте всегда закрытыми private, кроме констант. Это упрощает использование инвариантов путём добавления простейших (часто — константных) функций доступа.
Допустимо объявлять данные класса как protected для использования в тестовых классах (например, при использовании Google Test) или других подобных случаях.
Порядок объявления
Располагайте похожие объявления в одном месте, выносите общие части наверх.
Определение класса обычно начинается с секции public:, далее идёт protected: и затем private:. Пустые секции не указывайте.
Внутри каждой секции группируйте вместе подобные декларации. Предпочтителен следующий порядок: типы (включая typedef, using, вложенные классы и структуры), константы, фабричные методы, конструкторы, операторы присваивания, деструкторы, остальные методы, члены данных.
Не размещайте в определении класса громоздкие определения методов. Обычно только тривиальные, очень короткие или критичные по производительности методы «встраиваются» в определение класса. См. также Встраиваемые функции.
ссылка на оригинал статьи https://habr.com/ru/post/532820/
Добавить комментарий