Данная статья служит шпаргалкой при написании классов с различными перегрузками операторов на примере тривиального класса строки (и ещё нескольких). Описанное здесь позволяет избежать копирования кода из одного конструктора или оператора в другой, что значительно снижает вероятность появления ошибок, но может привести к незначительному уменьшению производительности.
Ошибка?
Если читатель найдёт ошибки, неточности или что-то упущенное, то напишет в комментарии - я исправлю.
Оглавление
Правило трёх, пяти и нуля
Про правило трёх, пяти и нуля уже много написано, поэтому кратко:
Правило трёх: если классу нужен определяемый пользователем деструктор, конструктор копирования или оператор присваивания копированием, ему почти наверняка нужны все три.
Правило пяти: расширяет правило трех, включив в него конструктор перемещения и оператор присваивания перемещением.
Правило нуля: если ничего из вышеперечисленного не определяется пользователем вручную, то можно использовать конструкторы, деструкторы и операторы присваивания, которые автоматически генерирует компилятор.
Конструкторы, деструкторы и операторы присваивания по умолчанию
Далее нас будут интересовать правила генерации по умолчанию:
Конструктор по умолчанию компилятор создаст, если в классе нет никаких других конструкторов, - он инициализирует все члены класса согласно их типам (например, встроенные типы останутся не инициализированными, если это не
bool
, а объекты — будут инициализированы своими конструкторами по умолчанию).
String() = default;
Конструктор копирования и оператор присваивания копированием по умолчанию будет выполнять побитовую копию всех полей.
String(const String&) = default;
String& operator=(const String&) = default;
Конструктор перемещения и оператор присваивания перемещением будут выполнять перемещение полей (перехват ресурсов).
String(String&&) = default;
String& operator=(String&&) = default;
Так как условия создания всего этого нетривиальны хорошим решением будет явно писать, что из этого генерируется по умолчанию, а что удалено. Более того, во многих ситуациях (например, при написании классов для каких-либо систем, которые создаются единожды (Singleton)) стоит сразу удалять всё, кроме конструктора и деструктора, чтобы исключить случайное использование некорректных конструкторов и операторов присваивания, которые сгенерировал компилятор.
Cписки инициализации
Итак, приступим к написанию класса строки.
class String {
public:
String() : str_(nullptr), size_(0) {}
~String() {
delete[] str_;
}
private:
char* str_;
size_t size_;
};
Что-то непонятно?
Я не буду вдаваться в подробности кода, не касающегося непосредственно темы, так как подразумеваю, что читатель самостоятельно может найти информацию про непонятные места в интернете.
Уже в таком коде стоит сделать оговорку:
По возможности стоит всегда инициализировать поля через списки инициализации (str_(nullptr), size_(0)
), и на это есть 2 причины:
1) При выполнении кода внутри тела конструктора все поля уже инициализированы, поэтому нельзя в теле присвоить константу или ссылку.
class Test {
public:
Test(int& r, const int c) : ref_(r), const_(c) {}
private:
int& ref_;
const int const_;
}
2) По той же причине (инициализация полей до тела конструктора) могут быть вызваны лишние конструкторы по умолчанию, а только потом (уже в теле) оператор присваивания. Таким образом, какие-то времязатратные операции будут выполнены 2 раза вместо одного:
struct Inner {
Inner() {
// Complex operations will be performed first here
}
Inner(int i) {
// And then here
}
}
class Test {
public:
Test(int i) {
in_ = Inner(i);
}
private:
Inner in_;
}
Явные преобразования
Далее честно напишем конструктор от char*
:
explicit String(const char* s) : size_(strlen(s)) {
str_ = new char[size_ + 1];
std::copy(s, s + size_, str_);
str_[size_] = '\0';
}
Первым делом обратим внимание на модификатор explicit
. Хорошим тоном считается писать его в случаях, когда конструктор имеет лишь один параметр, потому что без него возможны неявные преобразования. Забегая немного вперёд, такой код спокойно компилируется (если убрать explicit
), но выводит мусор:
void func(String a) {
std::cout << a << std::endl;
}
char ch = 'a';
func(&ch);
Делегирующий конструктор
Далее напишем конструктор копирования:
String(const String& other) : str_(new char[other.size_ + 1]), size_(other.size_) {
std::copy(other.str_, other.str_ + other.size_, str_);
str_[size_] = '\0';
}
Здесь нам пришлось писать его полностью, так как в отличии от предыдущего конструктора мы уже имеем размер и можем его не считать с помощью strlen
. Однако в другой ситуации мы могли бы воспользоваться делегирующим конструктором. Т.е. вызвать в одном конструкторе другой.
String(const String& other) : String(other.str_) {}
Такое часто используется, если порядок параметров не важен.
Test(float b, int a) { /* Some logic */ }
Test(int a, float b) : Test(b, a) {}
Идиома копирования и замены
Чтобы завершить правило 3-х, напишем оператор присваивания копированием. В нём уже будет использоваться идиома копирования и замены (Copy-and-Swap Idiom). В 6-й строке мы вызываем уже написанный конструктор копирования, а потом меняем созданный и наш объект. Так как std::swap
реализован через перемещение, он должен работать за константное время, а локальный объект удалится по выходу из тела с помощью деструктора.
String& operator=(const String& other) {
if (this == &other) {
return *this;
}
String s = other;
std::swap(*this, s);
return *this;
}
Но перед этим есть строки, которые предотвращают лишнее копирование, если фактически никаких действий не происходит (мы присваиваем объекту самого себя). Часто можно увидеть код без этой проверки, тогда можно сразу передавать параметром объект, а не ссылку на него:
String& operator=(String other) {
std::swap(*this, other);
return *this;
}
Ещё одним немаловажным моментом является то, что возвращаем мы из функции ссылку на данный объект. Делается это, чтобы были возможны цепочки присваивания. Здесь сначала будет выполнено y = 5
, а потом уже x = y
.
x = y = 5;
Перемещение через std::exchange
И наконец, чтобы соблюдалось правило 5, реализуем конструктор перемещения и оператор присваивания перемещением. Для этого удобно использовать std::exchange
, работа которого в списках инициализации аналогична закомментированному коду в теле. Важно, что практически в этих двух случаях всегда нужно писать noexcept
, так как, по идее, в них не должно быть ничего, кроме операций с базовыми типами.
String(String&& other) noexcept : str_(std::exchange(other.str_, nullptr)),
size_(std::exchange(other.size_, 0)) {
// str_ = other.str_;
// other.str_ = nullptr;
// size_ = other.size_;
// other.size_ = 0;
}
String& operator=(String&& other) noexcept {
std::swap(str_, other.str_);
std::swap(size_, other.size_);
return *this;
}
Важная ремарка об операторах ниже
Для арифметических операторов и операторов сравнения показаны частые практики с оглядкой на числа, так как при выражении одних через другие используются математические свойства. Однако стоит быть внимательным, так как, например, тот же float
имеет много исключительных ситуаций (NaN, inf, -inf), при которых данная логика не работает.
Также стоит придерживаться простого правила: перегрузка оператора должна быть разумной и интуитивно понятной. Таким образом, не имеет смысла вычитать, умножать или делить две строки, но в данной ситуации мы пренебрежём данным правилом, так как код и смысл советов не поменяются.
Если конструктор или оператор имеют не тривиальное поведение стоит вынести его в отдельную функцию с понятным названием. Например, вычитание строки из строки - непонятное действие, а метод removeChars(String)
(убирает из строки символы, присутствующие в переданной строке-параметре) - хоть и не самое понятное название, но его сложно использовать по случайности.
Арифметические операторы
Все обычные арифметические операторы обычно можно перегрузить, написав код только для +=
и -
, так как остальное можно выразить из них. В нашем случае сложно придумать что-то вразумительное для унарного минуса, поэтому будем возвращать пустую строку.
String& operator+=(const String& other) {
char* new_str = new char[size_ + other.size_ + 1];
std::copy(str_, str_ + size_, new_str);
std::copy(other.str_, other.str_ + other.size_ + 1, new_str + size_);
delete[] str_;
str_ = new_str;
size_ += other.size_;
return *this;
}
const String operator-() const {
// Or some more complex logic
return {};
}
Константность операторов
Как можно заметить мы начинаем помечать перегрузки словом const
(тот, который после ()
), потому что данная функция не изменяет сам объект, а возвращает новый - было бы странно, если бы x = -y;
меняло бы y
. По возможности стоит писать const
везде, где только можно, чтобы потом не искать, почему какая-то операция не работает с const
объектом.
Иное значение имеет const
, который применяется к возвращаемому типу: так как мы возвращаем результат промежуточных вычислений, то было бы странно иметь возможность изменить его -String{} = String{}
.
Далее начнём выражать остальные операторы через имеющиеся. +
реализован через копирование и прибавление к новому объекту.
const String operator+(const String& other) const {
String s = *this;
return s += other;
}
Стоит отметить, что s
- локальный объект, а потому при возвращении значения из функции должно произойти ещё одно копирование с последующим удалением s
, а значит мы должны были бы использовать std::move
, чтобы вместо копирования произошло перемещение, но об этом можно не беспокоится, так как компилятор применяет RVO (Return Value Optimization).
Выражение одних операторов через другие
Теперь у нас есть всё для оставшихся операторов. Бинарный минус пишется через сложение с отрицательным значением.
const String operator-(const String& other) const {
return *this + (-other);
}
// Prefix increment
String& operator++() {
return *this += String(" ");
}
// Postfix increment
const String operator++(int) {
String s = *this;
*this += String(" ");
return s;
}
Принципиальная разница префиксного и постфиксного инкрементов в том, что префиксный просто изменяет объект, а постфиксный сохраняет копию объекта, изменяет объект и возвращает старое, сохранённое значение, поэтому правильно реализованный префиксный инкремент быстрее постфиксного.
Остальные арифметические операторы либо пишутся по аналогии, либо имеют свою полноценную логику.
Операторы сравнения
По аналогии с арифметическими операторы сравнения выражаются через <
(можно и через >
- разницы нет).
bool operator<(const String& other) const {
for (size_t i = 0; i < std::min(size_, other.size_) + 1; ++i) {
if (*(str_ + i) >= *(other.str_ + i)) {
return false;
}
}
return true;
}
Если поменять местами операнды, то больше превращается в меньше. Логично, что "не больше" - это "меньше или равно", а "не равно" - это "либо больше, либо меньше"...
bool operator>(const String& other) const {
return other < *this;
}
bool operator>=(const String& other) const {
return !(other < *this);
}
bool operator<=(const String& other) const {
return !(other > *this);
}
bool operator!=(const String& other) const {
return *this < other || *this > other;
}
bool operator==(const String& other) const {
return !(*this != other);
}
Перегрузка скобок
Когда мы хотим обратиться по индексу к массиву, мы пишем индекс в квадратных скобках. Это тоже оператор, который мы способны перегрузить, но с ним есть проблема, связанная с использованием полученной ссылки.
char& operator[](size_t i) {
return *(str_ + i);
}
Если объект не константный, то всё хорошо: мы возвращаем обычную ссылку на символ, который можем посмотреть и изменить. Но в случае константного объекта мы даже не сможем обратиться, так как перегрузка не константная, поэтому для неё приходиться писать второй вариант:
const char& operator[](size_t i) const {
return *(str_ + i);
}
Вектор булевых значений
Возвращаемый тип и параметры могут быть произвольными. Так часто при обращении по индексу возвращают не ссылку, а итератор или обёртку, которая ссылается на объект - это делается, если невозможно представить данные как стандартный тип. Например, std::vector<bool>
хранит 8 bool
'ов в одном байте, а ссылаться на отдельный бит невозможно, поэтому при обращении по индексу возвращается обёртка, которая знает, какую побитовую операцию и как применить, чтобы изменить конкретный бит.
// I skip lots of neccesery code
class BoolVector {
struct BoolWrapper {
BoolVector& v;
size_t x;
size_t y; // Can be char
BoolWrapper(BoolVector& v, size_t x, size_t y) : v(v), x(x), y(y) {}
BoolWrapper& operator=(bool b) {
v.arr_[x] = b ? char(v.arr_[x] | (1 << y)) : char(v.arr_[x] & ~(1 << y));
return *this;
}
};
public:
BoolWrapper operator[](size_t i) {
return {*this, i / 8, i % 8};
}
private:
char* arr_;
}
Преобразования
Неявные преобразования
В примере со строкой мы получаем ссылку на символ, с которой можем работать, а в примере с вектором мы не сможем получить bool
без использования метода. Поэтому мы хотим определить неявное преобразование, чтобы интерфейс и поведение не отличалось от других контейнеров.
operator bool() const {
return (v.arr_[x] >> y) & 1;
}
........
BoolVector bv;
bv.push_back(true);
bool b_from_bv = bv[0];
Явные преобразования
В данной ситуации неявное преобразование нужно, но как и в случае с конструкторами чаще всего мы хотим избежать его, поэтому помечаем преобразование словом explicit
.
explicit operator std::string() const {
return {str_};
}
Дружественные функции и классы
Стандартной задачей является перегрузка оператора вывода в поток (и ввода из потока), однако левый операнд - сам поток, и по хорошему мы должны писать перегрузку внутри класса потока, но мы не можем этого сделать, поэтому функция оператора не будет являться членом класса.
// Outside class
std::ostream& operator<<(std::ostream& os, const String& s) { ... }
Однако нам всё равно понадобиться приватное поле str_
, чтобы вывести его в поток, но так как функция оператора не является членом класса, она не имеет доступа к приватным полям. Чтобы решить эту проблему, обычно делают такие функции операторов дружественными (иногда правда создают какой-то публичный интерфейс), чтобы была возможность читать из объекта или писать в объект. А вместе с этим можно сразу реализовать всё в классе.
// Inside class
friend std::ostream& operator<<(std::ostream& os, const String& s) {
return os << s.str_;
}
// For next part
friend StringHash;
friend std::hash<String>;
Перегрузка круглых скобок, функторы
Также как и квадратные скобки можно перегружать круглые, однако в их случае мы затрагиваем понятие "функтор" - объект, который можно вызвать как функцию.
class Functor {
public:
size_t my_i;
void operator()() const {
std::cout << "I call operator(). " << my_i << std::endl;
}
void operator()(size_t i, const std::string& s, ...) {
my_i = i;
std::cout << "I call operator() with many parameters. " << i << ' ' << s << std::endl;
}
};
Functor a;
a(1, "2", 3.);
a();
Хеширование для unordered контейнеров
Чаще всего с ними можно встретиться при создании unordered
контейнеров, так как они используют хеширование для быстрой работы, а пользовательские типы не имеют перегрузки для std::hash
(это структура). И тут 2 варианта: создать эту перегрузку или сделать функтор, который будет передан как шаблонный параметр.
struct StringHash {
size_t operator()(const String& s) const {
return std::hash<char*>{}(s.str_);
}
};
template<> struct std::hash<String> {
size_t operator()(const String& s) const {
return std::hash<char*>{}(s.str_);
}
};
std::unordered_set<String, StringHash> some_set1;
std::unordered_set<String> some_set2;
Операции с указателями
Когда вы пишете итератор для собственного контейнера или собственный умный указатель вам понадобятся операторы разыменования и выбора члена.
template<typename T>
class AutoPtr {
public:
template<typename... Args>
explicit AutoPtr(Args... args) : ptr_(new T(std::forward<Args...>(args)...)) {
}
explicit AutoPtr(T* arg) : ptr_(arg) {
}
AutoPtr(const AutoPtr&) = delete;
AutoPtr(AutoPtr&&) = delete;
AutoPtr& operator=(const AutoPtr&) = delete;
AutoPtr& operator=(AutoPtr&&) = delete;
~AutoPtr() {
delete ptr_;
}
T& operator*() const {
return *ptr_;
}
T* operator->() const {
return ptr_;
}
// Do not overload unary operator&, it is dangerous.
private:
T* ptr_;
};
AutoPtr<String> p("boba");
std::cout << *p << std::endl;
Здесь логика проста: при разыменовании мы возвращаем ссылку, так как это именно то, что происходит с обычными указателями. А при обращении к члену - нет: мы возвращаем то, к чему можно применить обращение к члену, т.е. можно даже выстроить целую цепочку обращений к члену.
struct Test {
struct Inner {
std::string ptr_{"abob"};
std::string* operator->() {
return &ptr_;
}
} in;
Inner& operator->() {
return in;
}
};
Test comp;
std::cout << comp->data() << std::endl;
Остальное и итог
Ещё можно перегрузить выделение и удаление памяти, но это уже отдельная большая тема аллокаторов, про которую лучше почитать в другом месте.
void* operator new(size_t size);
void operator delete (void* ptr);
А также есть операторы, которые не рекомендовано перегружать - это &&
, ||
, ,
и унарный &
. Первые три имеют определённый порядок вычислений в стандарте - слева на право, а первые два - ещё и семантику быстрых вычислений (если результат выражения ясен уже после вычисления левого операнда, то правый не вычисляется), которая теряется при перегрузке, поэтому она ведёт себя как обычный вызов функции, даже если они используются без нотации вызова функций.
С другой стороны если получение адреса применяется к lvalue неполного типа, а полный тип объявляет перегруженный operator&
, то поведение зависит от компилятора: вызовется оператор по умолчанию или перегрузка. Поэтому часто вместо этого оператора используют функцию std::adressof
.
Многие моменты я упустил, но основное, вроде, рассказал. Нашёл несколько ошибок в старых проектах, а также жутко устал. Удачи, читатель.
Весь код
Весь код
#include <iostream>
#include <cstring>
#include <utility>
#include <vector>
#include <unordered_set>
struct StringHash;
class String {
public:
#pragma region RuleOfFive
String() : str_(nullptr), size_(0) {}
explicit String(const char* s) : size_(strlen(s)) {
str_ = new char[size_ + 1];
std::copy(s, s + size_, str_);
str_[size_] = '\0';
}
// String(const String& other) : String(other.str_) {
// }
String(const String& other) : str_(new char[other.size_ + 1]), size_(other.size_) {
std::copy(other.str_, other.str_ + other.size_, str_);
str_[size_] = '\0';
}
// String(String&& other) noexcept {
// str_ = other.str_;
// other.str_ = nullptr;
// size_ = other.size_;
// other.size_ = 0;
// }
String(String&& other) noexcept : str_(std::exchange(other.str_, nullptr)), size_(std::exchange(other.size_, 0)) {
}
// String& operator=(String other) {
// std::swap(*this, other);
//
// return *this;
// }
String& operator=(const String& other) {
if (this == &other) {
return *this;
}
String s = other;
std::swap(*this, s);
return *this;
}
String& operator=(String&& other) noexcept {
std::swap(str_, other.str_);
std::swap(size_, other.size_);
return *this;
}
~String() {
delete[] str_;
}
#pragma endregion // RuleOfFive
#pragma region Arithmetic
String& operator+=(const String& other) {
char* new_str = new char[size_ + other.size_ + 1];
std::copy(str_, str_ + size_, new_str);
std::copy(other.str_, other.str_ + other.size_ + 1, new_str + size_);
delete[] str_;
str_ = new_str;
size_ += other.size_;
return *this;
}
// const String operator+(const String& other) const {
// String s = *this;
// s += other;
// return s;
// }
const String operator+(const String& other) const {
String s = *this;
return s += other;
}
const String operator-() const {
// Or some more complex logic
return {};
}
const String operator-(const String& other) const {
return *this + (-other);
}
String& operator++() {
return *this += String(" ");
}
const String operator++(int) {
String s = *this;
*this += String(" ");
return s;
}
#pragma endregion // Arithmetic
#pragma region Compression
bool operator<(const String& other) const {
for (size_t i = 0; i < std::min(size_, other.size_) + 1; ++i) {
if (*(str_ + i) >= *(other.str_ + i)) {
return false;
}
}
return true;
}
bool operator>(const String& other) const {
return other < *this;
}
bool operator>=(const String& other) const {
return !(other < *this);
}
bool operator<=(const String& other) const {
return !(other > *this);
}
bool operator!=(const String& other) const {
return *this < other || *this > other;
}
bool operator==(const String& other) const {
return !(*this != other);
}
#pragma endregion // Compression
#pragma region AcessByIndex
char& operator[](size_t i) {
return *(str_ + i);
}
const char& operator[](size_t i) const {
return *(str_ + i);
}
#pragma endregion // AcessByIndex
explicit operator std::string() const {
return {str_};
}
friend std::ostream& operator<<(std::ostream& os, const String& s) {
return os << s.str_;
}
private:
char* str_;
size_t size_;
friend StringHash;
friend std::hash<String>;
};
// Incomplete class
class BoolVector {
struct BoolWrapper {
BoolVector& v;
size_t x;
size_t y;
BoolWrapper(BoolVector& v, size_t x, size_t y) : v(v), x(x), y(y) {}
BoolWrapper& operator=(bool b) {
v.arr_[x] = b ? char(v.arr_[x] | (1 << y)) : char(v.arr_[x] & ~(1 << y));
return *this;
}
operator bool() const {
return (v.arr_[x] >> y) & 1;
}
};
public:
BoolWrapper operator[](size_t i) {
return {*this, i / 8, i % 8};
}
private:
char* arr_;
};
class Functor {
public:
size_t my_i;
void operator()() const {
std::cout << "I call operator(). " << my_i << std::endl;
}
void operator()(size_t i, const std::string& s, ...) {
my_i = i;
std::cout << "I call operator() with many parameters. " << i << ' ' << s << std::endl;
}
};
struct StringHash {
size_t operator()(const String& s) const {
return std::hash<char*>{}(s.str_);
}
};
template<> struct std::hash<String> {
size_t operator()(const String& s) const {
return std::hash<char*>{}(s.str_);
}
};
template<typename T>
class AutoPtr {
public:
template<typename... Args>
explicit AutoPtr(Args... args) : ptr_(new T(std::forward<Args...>(args)...)) {
}
explicit AutoPtr(T* arg) : ptr_(arg) {
}
AutoPtr(const AutoPtr&) = delete;
AutoPtr(AutoPtr&&) = delete;
AutoPtr& operator=(const AutoPtr&) = delete;
AutoPtr& operator=(AutoPtr&&) = delete;
~AutoPtr() {
delete ptr_;
}
T& operator*() const {
return *ptr_;
}
T* operator->() const {
return ptr_;
}
// Do not overload unary operator&, it is dangerous.
private:
T* ptr_;
};
void func(String a) {
std::cout << a << std::endl;
}
struct Test {
struct Inner {
std::string ptr_{"abob"};
std::string* operator->() {
return &ptr_;
}
} in;
Inner& operator->() {
return in;
}
};
int main() {
String s1;
String s2{"Aboba"};
// s1 + s2 = String("aboba");
// char ch = 'a';
// func(&ch);
// BoolVector bv;
// bv.push_back(true);
// bool b_from_bv = bv[0];
Functor a;
a(1, "2", 3.);
a();
std::unordered_set<String, StringHash> some_set1;
std::unordered_set<String> some_set2;
AutoPtr<String> p("boba");
std::cout << *p << std::endl;
Test comp;
std::cout << comp->data() << std::endl;
return 0;
};
TheCalligrapher
Во-первых, что это за странная оговорка про
bool
? Чемbool
вдруг отличается от других?Во-вторых, что это за странное использование термина "объекты"? А что объекты встроенных типов - это не объекты?
Что за дичайшая дичь? Какая еще "побитовая копия"? Никогда и нигде в С++ не делаются никакие "побитовые копии", кроме разве что
std::memcpy
, которую вы сами руками вызвали ."Перемещение" в С++ - концепция чисто пользовательского уровня. "Перемещение" может написать только вы своими руками. Конструктор перемещения и оператор присваивания перемещением будет делать именно и только то, что вы сами в нем напишете (за исключением автоматического делегирования в рамках "правила нуля"). "Встроенная" поддержка перемещения есть только у фундаментальных типов, и у них перемещение совпадает с копированием. Поэтому не ясно, о каком "будут выполнять перемещение" здесь идет речь.
Ну вообще-то с С++11 у нас есть альтернативная возможность: указывать инициализаторы прямо в объявлениях полей, которая во многих случаях (в большинстве?) является лучшей идеей, чем ясное прописывание списков инициализации.
Непонятно тогда, почему в разделе про
std::exchange
при реализации оператора присваиванияstd::exchange
не использовалось, а использоваласьstd::swap
. Это совсем другая идиома. Так о како именно идиоме вы хотели написать в этом разделе?Непонятно, почему в реализации используется странный инопланетный синтаксис
*(str_ + i)
. Чем обычное человеческоеstr_[i]
не угодило?Для представления бинарных данных использован тип
char
??? Неunsigned char
?