Реализации объектно-ориентированного программирования в разных Си-подобных языках, конечно, похожи, и все такие языки, созданные после C++, пытаются сделать ООП более удобно используемым. Сравним в этой статье ООП в D и С++.
Структуры и классы в C++ — это фактически одно и то же (хотя на практике используются по-разному), но в D есть явная семантическая разница. Структуры в D в основном предназначены для простой инкапсуляции данных и функций в единой сущности. Наследовать структуры нельзя, а память под структуры чаще всего выделяется на стеке. Классы же можно наследовать друг от друга, а объекты классов выделяются (почти всегда) в куче, контролируемой сборщиком мусора.
Структуры
Поля и методы
Для начала сравнения D и C++ лучше всего подходят структуры. Давайте начнём с описания двумерной точки.
struct Point { int x; int y; };
Код структуры и на C++, и на D одинаковый, за тем исключением, что в D после фигурных скобок не нужно ставить точку с запятой.
Попробуем воспользоваться нашим новым типом данных, инициализировав и распечатав объект. Начнём с D:
import std.stdio : writeln; struct Point { int x; int y; } int main() { auto p = Point(120, 205); writeln(p); return 0; }
Запустим программу с rdmd
:
$ rdmd point.d Point(120, 205)
К сожалению, в C++ нельзя просто так взять и распечатать структуру. Печальная судьба, но всё же мы можем перегрузить <<
для ostream
, чтобы добиться нужного эффекта. Плюс, в C++20 появилась функция format()
.
#include <iostream> #include <format> struct Point { int x; int y; }; std::ostream& operator<<(std::ostream& out, const Point& p) { out << std::format("Point({}, {})", p.x, p.y); return out; } int main() { Point p{120, 205}; // в С++20 будут работать и круглые скобки std::cout << p << std::endl; return 0; }
$ g++ point.cpp -std=c++20 $ ./a.out Point(120, 205)
Ну и раз уж пошла такая пьянка, надо показать, как сделать своё преобразование структуры в строку на D, чтобы заодно переопределить поведение в функции writeln()
.
import std.format : format; struct Point { int x; int y; string toString() const { return format("x: %d, y: %d", x, y); } }
$ rdmd point.d x: 120, y: 205
Можно теперь вызвать writeln(p.toString());
вместо передачи writeln
целой структуры — таким образом мы избежим лишних вызовов неявного конструктора копирования.
Мы только что увидели, как определить свой метод в D. Мы можем на C++ сделать аналогичный метод и выглядеть это будет практически так же:
#include <string> #include <format> struct Point { int x; int y; std::string to_string() const { return std::format("x: {}, y: {}", x, y); } }; std::ostream& operator<<(std::ostream& out, const Point& p) { out << p.to_string(); return out; }
В конце сигнатуры перед телом функции мы вписали в обоих случаях const
, это показывает, что метод не меняет состояние объекта и позволяет методу выполняться для константных объектов.
Примечание.
В C++ есть соглашение, по которому для получения строки из пользовательского типа данных объявляется функция to_string()
, принимающая константный объект. Для порядка мы можем дописать:
std::string to_string(const Point& point) { return point.to_string(); }
Конструкторы структур
Конструктор — это метод, использующийся при инициализации объекта. В D конструктор обозначается ключевым словом this
, в C++ — именем типа данных.
Вероятно, вы заметили, что в C++ мы использовали список инициализации для заполнения полей. В D такой необходимости нет (да и вообще фигурные скобки для инициализации не используются) — для структуры неявно создаётся набор конструкторов для последовательного заполнения элементов. Т.е. если мы вызовём Point()
, поля останутся со значениями по умолчанию; выражение Point(7)
тоже валидно — будет инициализирован только элемент x
значением 7
. Как только будет создан хоть один пользовательский конструктор, эта возможность закрывается. Но есть нюанс: свой конструктор по умолчанию для D-структур создать нельзя:
struct Point { int x; int y; this() {} // Ошибка! }
Для любых типов данных в D есть их значение по умолчанию T.init
; значение по умолчанию для структуры будет состоять из начальных значений внутренних элементов (в данном случае, в x
и y
будут нули). Поэтому конструктор по умолчанию не должен существовать — мы должны знать сразу значения всех полей, а конструктор мог бы выполнять вообще произвольные действия.
В свою очередь, на C++ закроется возможность использовать список инициализации из двух элементов после объявления пользовательского конструктора.
struct Point { int x; int y; Point() { // пользовательский конструктор по умолчанию x = 0; y = 0; } }
Теперь нельзя написать Point p{120, 205}
; но можно написать Point p{}
или совсем без каких-либо скобок. Когда появится конструктор, принимающий два аргумента типа int
, вновь можно будет создавать объект списком инициализации (как это было выше).
Примечание.
В современном C++ принято использовать фигурные скобки для инициализации переменных в большинстве случаев. Но в этой истории есть много нюансов, а ад инициализации не является предметом рассмотрения данной статьи; в D нет такого использования фигурных скобок и нет явлений, идентичных std::initializer_list
. Ко всему прочему эта тема не имеет отношения к ООП, и для большей близости кода на двух языках мы будем в дальнейшем повествовании в основном использовать синтаксические традиции C++98.
В C++ this
выполняет роль указателя на текущий объект. В D тоже такая роль у данного ключевого слова, но есть разница в синтаксисе обращения к полям через указатель: в C++ используется оператор ->
, в D — точка. Например, конструктор класса Point
может быть написан так:
Point(int x, int y) { this->x = x; this->y = y; }
this(int x, int y) { this.x = x; this.y = y; }
Из контекста компиляторам понятно, где аргументы, а где поля класса.
Деструкторы структур
Деструктор выполняется тогда, когда объект завершает свой жизненный цикл (обычно, при завершении блока с областью видимости объекта). Явное описание деструктора может потребоваться когда объекту необходимо в конце жизни удалить временные файлы, отключиться от базы данных, освободить память или ещё какой-нибудь ресурс. В C++ деструктор объявляется как функция с тильдой перед именем класса, например, ~Point()
, в D — ~this()
.
Немного о модификаторах доступа
В D все поля структур и классов имеют по умолчанию атрибут public
, как в структурах C++. В C++ разница между структурами и классами проявлятся только в том, что в классах всё private
по умолчанию. Смысл самих ключевых слов private
и public
в данных языках в контексте структур и классов (почти) совпадает, т.е. поля/методы private
доступны только структуры/класса, а к полям и методам public
можно обращаться снаружи. Их роль при наследовании и модификатор protected
будут описаны в теме, касающейся классов.
В D есть небольшое синтаксическое дополнение — модификатор доступа можно ставить непосредственно перед полем или методом, а также можно обрамлять области с полями и методами при помощи фигурных скобок:
import std.format : format; struct Point { private int x; private int y; public { string toString() const { return format("x: %d, y: %d", x, y); } int getX() { return x; } int getY() { return y; } } }
Но в общем можно всегда использовать привычный синтаксис меток с двоеточиями из C++.
Конструктор копирования
Для того, чтобы окунуться глубже в сравнение, надо создать нечто чуть более сложное, чем просто тип с двумя целыми числами. Создадим структуру, абстрагирующую память из кучи. Будем использовать обычный Си-шный malloc()
для идентичности кода на двух языках. Для демонстрации запишем в память числа 1, 2, 4, 8.
C++:
#include <cstring> #include <iostream> #include <cmath> struct Memory { private: void* p = nullptr; size_t size = 0; public: Memory(size_t size) { std::cout << "main ctor" << std::endl; this->p = malloc(size); if (this->p == nullptr) { auto msg = "Memory allocation error."; throw std::runtime_error(msg); } this->size = size; } Memory(const Memory& rhs) : Memory(rhs.size) { // обратите внимание на делегирующий конструктор после ":" выше std::cout << "copy ctor" << std::endl; memcpy(this->p, rhs.p, rhs.size); } ~Memory() { std::cout << "dtor" << std::endl; free(p); } void* get_ptr() const noexcept { return this->p; } size_t get_size() const noexcept { return this->size; } }; void copy_and_print(const Memory mem) { int* arr = static_cast<int*>(mem.get_ptr()); for (size_t i = 0; i < mem.get_size() / sizeof(int); i++) { std::cout << arr[i] << " "; } std::cout << std::endl; } int main() { size_t n_elem = 4; Memory mem(n_elem * sizeof(int)); int* arr = static_cast<int*>(mem.get_ptr()); for (size_t i = 0; i < n_elem; i++) { arr[i] = pow(2, i); } copy_and_print(mem); return 0; }
D:
import core.stdc.stdlib : malloc, free; import core.stdc.string : memcpy; import std.stdio : write, writeln; struct Memory { private: void* p = null; size_t size = 0; public: this(size_t size) { writeln("main ctor"); this.p = malloc(size); if (this.p == null) { auto msg = "Memory allocation error."; throw new Exception(msg); } this.size = size; } this(ref return scope const Memory rhs) { writeln("copy ctor"); this(rhs.size); memcpy(this.p, rhs.p, rhs.size); } ~this() { writeln("dtor"); free(this.p); } void* getPtr() nothrow { return this.p; } size_t getSize() const nothrow { return this.size; } } void copyAndPrint(Memory mem) { int* arr = cast(int*) mem.getPtr(); for (size_t i = 0; i < mem.getSize() / int.sizeof; i++) { write(arr[i], " "); } writeln(); } int main() { size_t nElem = 4; auto mem = Memory(nElem * int.sizeof); int* arr = cast(int*) mem.getPtr(); for (size_t i = 0; i < nElem; i++) { arr[i] = 2 ^^ cast(int)i; } copyAndPrint(mem); return 0; }
Как говорится, найдите 10 отличий… А если серьёзно, можете скопировать код двух языков в редактор так, чтобы один пример был напротив другого: строки кода написаны с почти полным соответствием смысла и тем удобнее сравнивать. Спросите, где вызов конструктора копирования? Он вызывается неявно при передаче структуры в фукнцию по значению.
Обычный конструктор и деструктор двух реализаций выглядят почти одинаково: в конструкторе выделяется память при помощи malloc()
, в деструкторе освобождается при помощи free()
, ничего особенного. Вот с конструктором копирования интереснее. В нём нам нужно вызвать другой конструктор, который занимается выделением памяти, а затем скопировать содержимое памяти из оригинального объекта. В C++ один конструктор в теле другого конструктора с этой целью вызвать нельзя, поэтому он вызывается с использованием делегирующего конструктора в списке инициализации полей объекта. В D списка инициализации полей вообще нет, и синтаксис конструктора позволяет вызывать один конструктор в другом ( в нашем случае это выглядит как this(rhs.size);
в теле конструктора). Если похожим образом поступить в C++, выйдет создание анонимного временного объекта. Читатель может законно спросить, что это за экзотика такая — ref return scope const
перед аргументом конструктора копирования в D. При помощи ref
объект передаётся в функцию по ссылке (в D нет специального ссылочного типа с амперсандом как в C++), а return scope
лишь показывает, что память из передающегося аргумента никуда не утечёт и останется только в рамках вызванной функции и той области видимости, из которой вызвана (в ином случае конструктор мог бы сохранить что-то из ссылочных полей, например, в глобальных переменных или ещё где; проблема могла бы возникнуть, если мы куда-то сохранили бы адрес какого-нибудь поля, который бы случайно прожил дольше объекта), т.е. это нужно для большей безопасности памяти. В общем случае это просто декларация о том как должно быть, а реальная проверка того, что там где выходит за пределы дозволенной области видимости осуществляется только для @safe
-функций, но в эту тему сейчас углубляться не будем.
Примечание.
Раньше конструктор копирования в D объявлялся со специально изобретённым синтаксисом — this(this) { ... }
, который иначе назывался postblit
, о чём написано, например, в книге А.Александреску, изданной в оригинале ещё в 2010-м. Сейчас этот способ считается устаревшим.
В теле функции main()
мы просто берём и пробуем использовать память, выделенную в структуре Memory
, как будто это указатель на массив из четырёх элементов типа int
. Сначала заполняем, а потом выводим. Функции печати сделаны специально такими, чтобы конструктор копирования сработал (можете проверить вставкой отладочных сообщений). Деструктор вызывается дважды: в конце copy_and_print()/copyAndPrint()
и в конце функции main()
, когда область видимости объекта заканчивается.
В обоих языках неявно есть конструктор копирования (пока мы не объявили собственный). Что касается оператора присвоения «=», то он напрямую связан с конструктором копирования, пока поведение оператора специально не переопределено.
А теперь о серьёзном. Внимательный читатель заметил фатальный недостаток реализации функции печати на D: объект передан не как константа. Если бы мы объявили функцию как void copyAndPrint(const Memory mem)
, это привело бы к тому, что метод getPtr()
нужно было бы объявить как способный вызываться для константного объекта. Но если объект константный, мы не можем вернуть void*
, т.к. указатель (поле p
) уже имеет тип const(void*)
, а не void*
, а неявное преобразование константного указателя к неконстантому не работает. Мы могли бы использовать грязный хак, используя явное преобразование:
void* getPtr() const nothrow { return cast(void*)this.p; }
Но тогда у якобы константного объекта мы смогли бы менять содержимое памяти под указателем, чего нам, вероятно, не хотелось бы, если уж объект константный. В D есть элегантное решение данного вопроса: ключевое слово inout
, на место которого (когда надо) компилятором подставляется const
, immutable
или ничего — это зависит от объекта:
inout(void*) getPtr() inout nothrow { return this.p; }
А функция печати пусть теперь выглядит так:
void copyAndPrint(const Memory mem) { // теперь const auto arr = cast(const int*) mem.getPtr(); // теперь const for (size_t i = 0; i < mem.getSize() / int.sizeof; i++) { write(arr[i], " "); } writeln(); }
И всё безопасно.
На самом деле const
имеет немного отличный от C++ смысл, гляньте на код ниже. В комментариях указано, что будет выведено.
import std.stdio; void main() { int x = 1; writeln(typeid(x)); // int const int* p1 = &x; writeln(typeid(p1)); // const(const(int)*) const(int*) p2 = &x; writeln(typeid(p2)); // const(const(int)*) const(int)* p3 = &x; writeln(typeid(p3)); // const(int)* }
Т.е. фактически const T*
и const(T*)
— это константный указатель на константу. (Стиль C/C++
константного указателя на константу в виде const int* const p = &x;
в D не скомпилируется.) На C/C++ const T*
означает неконстантный указатель на константные данные.
Как вы понимаете, в коде на C++ тоже есть проблема: хоть get_ptr()
и объявлен как константный метод, ничего не помешает изменить содержимое памяти под возвращённым указателем. (Если объект константный, то и его поля константные, но не данные под константным указателем.) Есть решение, заключающееся в том, что мы делаем два разных объявления метода — для неконстантного объекта и константного:
void* get_ptr() noexcept { return this->p; } const void* get_ptr() const noexcept { return this->p; }
Функцию печати тоже чуть исправляем, иначе static_cast
не сработает:
void copy_and_print(const Memory mem) { auto arr = static_cast<const int*>(mem.get_ptr()); // теперь const for (size_t i = 0; i < mem.get_size() / sizeof(int); i++) { std::cout << arr[i] << " "; } std::cout << std::endl; }
Семантика перемещения в C++
Изучая ООП к контексте C++, нельзя избежать таких тем как ссылка на rvalue и конструктор перемещения. В упрощении, rvalue — то¸что стоит справа от знака присваивания (и может быть чему-то присвоено), lvalue — то, что стоит слева от знака присваивания (то, чему присваивается). А ссылки на rvalue придуманы, чтобы затыкать некоторые проблемные моменты. Рассмотрим простую функцию, которая принимает целочисленный аргумент по обычной ссылке, увеличивает его и распечатывает.
#include <iostream> void fn1(int& arg) { arg++; std::cout << arg << std::endl; } int main() { int x = 5; fn1(x); // 6 }
Вроде всё хорошо, x
увеличится на единицу и распечатается. Но такой код не скомпилируется:
fn1(5);
Это работать не будет, потому что 5
— это заведомо временное значение, rvalue, у него нет никакого адреса и мы не можем использовать ссылку на него и (тем более) инкрементировать. Казалось, бы, если мы принимаем аргумент по неконстантной ссылке, странно в принципе пихать туда временное значение¸ но не всегда нам может быть важен факт модификации переменной извне, может иметь большее значение то, что происходит дальше (в нашем примитивном случае, печать). Жизнь временного значения можно продлить через rvalue-ссылку, которая объявляется посредством двух амперсандов:
#include <iostream> void fn1(int& arg) { arg++; std::cout << arg << std::endl; } void fn2(int&& rvref) { rvref++; std::cout << rvref << std::endl; } int main() { int x = 5; fn1(x); // 6 fn2(5); // 6 // fn2(x); // нельзя так fn2(std::move(x)); // а так сработает! }
Эта пятёрка действительно инкрементируется, вызов fn2(5);
выводит «6». Правда, вызвать fn2(x)
уже не получится, т.к. x
представляет собой lvalue, но мы можем привести его к ссылке на rvalue, чем и занимается фукнция std::move
. (Эта шаблонная фукнция ничего не перемещает, несмотря на название, она только делает приведение к ссылке на rvalue.)
Цель конструктора перемещения в том чтобы создать новый объект из старого таким образом, что старый объект становится невалидным, что позволяет избежать потенциально сложного выполнения копирования (но всё равно должен оставаться способным к уничтожению, т.е. деструктор объекта всё ещё должен корректно отрабатывать). Напишем конструктор перемещения для нашей структуры Memory
:
Memory(Memory&& rhs) { std::cout << "move ctor" << std::endl; this->p = rhs.p; rhs.p = nullptr; this->size = rhs.size; rhs.size = 0; }
У нас уже написана функция, которая копирует и распечатывает нашу память, теперь же давайте напишем функцию с семантикой перемещения:
void print_rvalue(Memory&& mem) { int* arr = static_cast<int*>(mem.get_ptr()); for (size_t i = 0; i < mem.get_size() / sizeof(int); i++) { std::cout << arr[i] << " "; } std::cout << std::endl; }
Для использования фукнции нужно передать ей временное значение (т.е. то, что явно rvalue), для чего изобретём функцию, выделяющую память под набор значений типа int
, представляющих собой степени заданного числа.
/* ... */ Memory alloc_powers(int degree_base, unsigned int n_elem) { if (degree_base == 0) { std::cerr << "Memory not filled." << std::endl; return Memory(n_elem * sizeof(int)); } Memory mem(n_elem * sizeof(int)); int* arr = static_cast<int*>(mem.get_ptr()); for (unsigned int i = 0; i < n_elem; i++) { arr[i] = pow(degree_base, i); } return mem; } int main() { print_rvalue(alloc_powers(2, 4)); // печатает "1 2 4 8" }
В общем случае компилятор старается сделать оптимизацию (N)RVO, при которой объект создаётся в нужной точке без копирования или перемещения, но мы написали такую функцию alloc_powers()
, с которой компилятору такое совершить сложно (и g++ 14
это не делает; а вообще оптимизацию можно отключить флагом -fno-elide-constructors
), поэтому мы увидим отладочное сообщение move ctor
, печатаемое из определённого нами конструктора перемещения. Если бы мы просто вызвали print_rvalue(Memory(8))
, не вызывался бы ни конструктор перемещения, ни конструктор копирования, объект был бы просто создан внутри функции.
Конструктор перемещения может использоваться, например, в методе push_back()
контейнера std::vector
, этот метод специально перегружен для случаев ссылки на rvalue.
/* ... */ std::vector<Memory> vec; vec.push_back(std::move(mem)); // старый mem больше не валиден, но его данные есть в векторе
Семантика перемещения в D
У D нет ссылок на rvalue, равно как и понятия конструктора перемещения. Но есть функция move()
из std.algorithm.mutation
. Она копирует содержимое одного объекта в другой, а старый затирается своим значением по умолчанию (напоминаю, у любой структуры в D есть начальное значение T.init
).
Хоть явного синтаксиса для ссылок на rvalue в D нет, ссылка на rvalue может быть иногда полезна. У компиляторов dmd
и ldc2
есть опция -preview=rvaluerefparam
, в gdc
аналогичная опция выглядит как -fpreview=rvaluerefparam
. Данная опция позволяет через ref-параметры функций передавать rvalue:
import std.stdio; void fn(ref int arg) { arg++; writeln(arg); } void main() { int x = 1; fn(x); // обычный случай fn(5); // это тоже работает! }
В будущем такое поведение может стать поведением по умолчанию.
В связи со сказанным мы можем написать код, похожий по C++-иевый:
/* ... */ void printRef(ref Memory mem) { auto arr = cast(const int*) mem.getPtr(); for (size_t i = 0; i < mem.getSize() / int.sizeof; i++) { write(arr[i], " "); } writeln(); } Memory allocPowers(int degreeBase, uint nElem) { if (degreeBase == 0) { writeln("Memory not filled."); return Memory(nElem * int.sizeof); } auto mem = Memory(nElem * int.sizeof); int* arr = cast(int*) mem.getPtr(); for (size_t i = 0; i < nElem; i++) { arr[i] = degreeBase ^^ i; } return mem; } int main() { printRef(allocPowers(2, 4)); // печатает "1 2 4 8" return 0; }
К сожалению, таким способом идеальной передачи не достичь — будет создан временный объект, который будет скопирован конструктором копирования при передаче в printRef()
, потому что конструктора перемещения не существует в D. Но решение есть: использовать move
:
import std.algorithm.mutation : move; Memory allocPowers(int degreeBase, uint nElem) { if (degreeBase == 0) { writeln("Memory not filled."); return move(Memory(nElem * int.sizeof)); // здесь !!! } auto mem = Memory(nElem * int.sizeof); int* arr = cast(int*) mem.getPtr(); for (size_t i = 0; i < nElem; i++) { arr[i] = degreeBase ^^ i; } return move(mem); // и здесь !!! }
Теперь при передаче объекта из allocPowers()
в вызывающий код вообще не будет вызван ни один конструктор, новый объект просто заполнится полями из старого, а поля старого объекта занулятся (точнее, старому объекту будет присвоено значение Memory.init
). Когда в конце allocPowers()
будет вызван деструктор, освобождать ему будет нечего, т.к. указатель выставлен в null
и деструктор ничего не повредит.
Теперь, если мы подменим функцию printRef()
на copyAndPrint()
(которая принимает константный объект), поведение для наблюдателя будет ровно то же самое, конструктор копирования больше не вызовется.
Классы и наследование
Простой класс на C++
Классы в C++, в отличие от структур, по умолчанию используют модификатор доступа private
. В D и в структурах, и в классах — public
. Объявим маленький класс «Bird»:
#include <iostream> #include <cstdint> // для uint class Bird { private: uint age = 0; public: Bird(uint age) { this->age = age; } uint get_age() { return age; } void lay_egg() { std::cout << "The egg is laid." << std::endl; } virtual void who() { std::cout << "I'm a bird." << std::endl; } }; int main() { Bird bird(2); bird.who(); }
Здесь мы видим ключевое слово virtual
в объявлении одного из методов. Виртуальные методы — такие методы, поведение которых можно переопределять в классах-потомках. Методы вроде get_age()
и lay_egg()
переопределять не нужно — все птицы будут исполнять их одинаково.
Абстрактные классы и простое наследование в C++
Давайте сделаем класс абстрактным — добавим в него один чисто виртуальный метод, для которого не может быть разумной реализации. После who()
напишем:
virtual void tell() = 0;
Виртуальный метод, помеченный присвоением ему нуля, — абстрактный метод, поведение которого должно быть определено в потомках. Появление хотя бы одного абстрактного метода делает весь класс абстрактным, и теперь мы не можем создавать объекты класса Bird
. Пришло время объявить потомка и переписать функцию main()
:
/* ... */ class Duck : public Bird { public: Duck(uint age = 0) : Bird(age) {} virtual void who() override { std::cout << "I'm a duck." << std::endl; } virtual void tell() override { std::cout << "Quack-quack!" << std::endl; } }; int main() { Duck duck(1, 3); duck.who(); duck.tell(); }
Класс объявлен как публично наследованный (: public Bird
), что означает, что все применённые модификаторы доступа в базовом классе Bird
останутся актуальными для Duck
, но то, что было private
, в классе-потомке недоступно к прямому использованию, т.е. к полю age
мы обращаться больше не можем. Почти всегда используется именно тип наследования public
, но создателями C++ по умолчанию почему-то выбран private
, который означает недоступность всех полей и методов предка из класса-потомка.
Если бы мы хотели, чтобы классы-потомки могли обращаться к полю age
, мы бы могли использовать промежуточный модификатор доступа — protected
, означающий доступность в потомках при public
— или protected
-наследовании, но недоступность извне.
Мы объявили функции who()
и tell()
как override
и тем самым обозначили, что это именно переопределённые методы. Можно обойтись и без этого, но эта штука может уберечь нас от ошибок. К примеру, методов who()
и tell()
могло не быть в базовом классе, хотя мы на это надеялись, и вместо переопределения получилось просто объявление новых методов. Или мы могли бы попытаться переопределить метод lay_egg()
, написав void lay_egg() override
, но переопределить его нельзя и мы бы сразу сели в лужу. Если мы определим метод lay_egg()
в Duck
, это будет просто перекрытие имени.
Конечно, во время перегрузки метода можно было обойтись без слова virtual
, т.к. метод не может перестать быть виртуальным, но всё же принято его писать.
Полиморфизм в C++
В C++ можно указатели на объекты классов-потомков неявно приводить к указателями на объекты класса-предка. Напишем ещё один класс-наследник Bird
, перепишем функцию main()
и добавим пару #include
:
/* ... */ #include <vector> #include <cstdlib> /* ... */ class Goose : public Bird { public: Goose(uint age = 0) : Bird(age) {} virtual void who() override { std::cout << "I'm a goose." << std::endl; } virtual void tell() override { std::cout << "Ga-ga-ga!" << std::endl; } }; int main() { std::vector<Bird*> birds; // инициализируем генератор псевдослучайных чисел srand(time(nullptr)); for (size_t i = 0; i < 8; i++) { if (rand() % 2 == 0) { birds.push_back(new Duck()); } else { birds.push_back(new Goose()); } } for (auto b : birds) { b->tell(); } // освобождаем память for (auto b : birds) { delete b; } }
Мы добавили класс гуся, создали вектор указателей на Bird
и заполнили его гусями и утками вперемешку. Во втором цикле по возгласам коллектива домашних птиц становится ясно кого мы понабрали; вывод может быть такой:
$ g++ birds.cpp $ ./a.out Quack-quack! Ga-ga-ga! Ga-ga-ga! Quack-quack! Quack-quack! Ga-ga-ga! Quack-quack! Quack-quack!
Т.к. под каждый объект выделялась память из кучи при помощи new
, её необходимо освободить при помощи delete
. Данная инструкция не просто освобождает память, но и вызывает деструктор. В более хорошем коде мы бы использовали умные указатели, но в этой статье мы стараемся использовать поменьше сущностей.
Благодаря полиморфизму мы можем делать вид, что Duck
или Goose
— это всё ещё Bird
. Благодаря невидимым для программиста таблицам виртуальных функций, исполняемой программе всегда понятно, какую именно версию tell()
нужно вызвать.
Виртуальный деструктор
Мы упустили в коде важный момент: если предполагается наследование, в базовом классе крайне небесполезно объявить виртуальный деструктор, хотя бы пустой:
virtual ~Bird() = default; // с пустым {} был бы тот же смысл
Ещё раз покажем, как в конце программы мы освобождаем набор объектов Bird
:
for (auto b : birds) { delete b; }
Инструкция delete
в нашем примере будет вызывать неявный дестуктор Bird
, а не деструкторы настоящих классов, если мы не объявили в Bird
виртуальный деструктор. Потомки класса Bird
могли иметь нетрививальные деструкторы, связанные, например, с освобождением динамической памяти, поэтому остро необходимо, чтобы при выполнении delete
вызывался бы правильный деструктор из таблицы виртуальных функций.
Всё то же самое, но для D
Полиморфизм, основанный на указателях, намекает, что объекты классов лучше всегда создавать в куче. Создатели D это учли и в нём объекты классов имеют ссылочную природу и создаются при помощи ключевого слова new
, которое выделяет память, контролируемую сборщиком мусора. Т.е. объект класса Duck
мы можем создать только с new
:
Duck duck = new Duck();
Приведём полный код, аналогичный «плюсовому»:
import std.stdio; import core.stdc.stdlib : srand, rand; import core.stdc.time : time; abstract class Bird { private uint age = 0; this(uint age) { this.age = age; } uint getAge() { return age; } void layEgg() { writeln("The egg is laid."); } void who() { writeln("I'm a bird."); } abstract void tell(); } class Duck : Bird { this(uint age = 0) { super(age); } override void who() { writeln("I'm a duck."); } override void tell() { writeln("Quack-quack!"); } } class Goose : Bird { this(uint age = 0) { super(age); } override void who() { writeln("I'm a goose."); } override void tell() { writeln("Ga-ga-ga!"); } } void main() { Bird[] birds; srand(cast(uint) time(null)); for (size_t i = 0; i < 8; i++) { if (rand() % 2 == 0) { birds ~= new Duck(); } else { birds ~= new Goose(); } } foreach (b; birds) { b.tell(); } }
Самая серьёзная разница в том, что в D все методы виртуальные. Т.е. их все можно перегружать, а перегрузка всегда обозначается словом override
. Если нет слова override
, то это просто перекрытие имени. Можно явно запретить перегружать метод, использовав final
:
final uint getAge() { return age; } final void layEgg() { writeln("The egg is laid."); }
То же самое, кстати, можно делать и в C++, но final
там пишется перед телом (имеет смысл только для виртуальных методов):
virtual uint get_age() final { return age; } virtual void lay_egg() final { std::cout << "The egg is laid." << std::endl; }
Примечание.
Ключевое слово final
можно использовать и для класса целиком, чтобы запретить наследоваться от него. В случае D оно пишется перед объявлением класса, а в C++ — перед телом.
В D наследование по умолчанию «публичное», поэтому не нужно лишний раз писать public
во время указания базового класса. И, напоминаю, что в D по умолчанию содержимое класса/структуры имеет модификатор доступа public
.
Тело функции main()
практически идентично ранее виданному на C++. Только вместо вектора используется встроенный в язык динамический массив (а инструкция «~=» присоединяет к массиву новый элемент) и нет явного освобождения памяти, поскольку её контролирует сборщик мусора. Для генерации псевдослучайных чисел можно было бы использовать более родные функции из std.random
, но мы использовали стиль Си для простоты (для C++ так-то тоже есть свои высокоуровневые средства в <random>
).
Кратко о других особенностях ООП в D
Интерфейсы
В D введена специальная сущность, обозначаемая как interface
, предназначенная для декларации того, какие методы должны определять классы-потомки.
Пример:
interface I { void method(); // метод для будущей реализации void bar() { } // ошибка(!), интерфейс не может предоставлять реализацию метода static void foo() { } // статические методы могут предоставлять реализацию final void abc() { } // финальные методы тоже }
Множественное наследование от классов в D запрещено, но возможно множественное наследование от интерфейсов, т.к. оно не создаёт особых проблем.
Уничтожение
Деструктор для классов можно объявлять так же как и для структур (~this() { /* ... */ }
), причём он всегда будет виртуальным. Он вызывается когда сборщик мусора решит, что «пора», но вы можете насильственно его вызвать с помощью встроенной функции destroy()
:
import core.stdc.stdlib : malloc, free; static import core.memory; import std.stdio : writeln; class C { void* memory; this(size_t size) { writeln("ctor"); memory = malloc(size); if (memory == null) { auto msg = "Memory allocation error."; throw new Exception(msg); } } ~this() { writeln("dtor"); free(memory); } } void main() { C obj = new C(1024); destroy(obj); writeln("The end"); }
Здесь всё хорошо, но нужно знать важный нюанс: сборщик мусора не гарантирует, что деструктор будет запущен для всех объектов, на которые нет ссылок. Если мы закомментируем или удалим строку с destroy
, потенциально в такой короткоживущей программе деструктор мог бы не быть вызван в конце выполнения main()
. В реальности, закомментировав строку с destroy()
, мы увидим вызов деструктора, но его могло бы и не быть. Класс может иметь в качестве полей объекты других классов, и может быть такая ситуация, что объект уже уничтожен, а его поля некоторое время ещё живут. Поэтому, если вам очень нужно правильно в нужном порядке освобождать ресурсы, явно используйте destroy()
для каждого требуемого объекта или создавайте специальные методы для работы с ресурсами.
Приведение потомков и родителей друг к другу
class A {} class B : A {} void main() { // пример 1 A obj1 = new B(); assert(obj1 !is null); // пример 2 B obj2 = cast(B) obj1; assert(obj2 !is null); // пример 3 B obj3 = cast(B) new A(); assert(obj3 is null); // не вышло }
Объекты классов-наследников всегда можно приводить к классам-родителям. Обратное может быть верным, если компилятор сумел определить, что изначально объект принадлежал тому самому наследнику (пример 2). Приведение явного объекта-родителя к объекту-наследнику — операция сомнительная, поэтому полученный объект не привязывается вообще ни к чему (пример 3).
Особое значение модификаторов доступа для D
Да, мы снова вернулись к этой теме, потому что ещё есть что сказать.
Сразу поясним: в D понятие модуля соответствует файлу, а понятие пакета — директории с файлами-модулями.
Спецификатор доступа private
(в отличие от такового в C++) ограничивает доступ до уровня модуля, т.е. все классы, структуры и функции, объявленные в одном и том же файле как бы «дружественны» друг другу.
К protected
есть доступ на уровне всего модуля, а также в классах-потомках. Имеет смысл только на уровне класса, не надо его применять к функциям, глобальным переменным или полям структуры.
К идентификаторам public
можно обращаться из любого места в программном коде.
Есть ещё особый модификатор доступа package
, предоставляющий доступ для пакета, т.е. для всех файлов директории, где находится текущий.
Композиция как альтернатива наследованию для структур
Вы можете при помощи конструкции вида alias this = field;
сделать текущую структуру подтипом другой структуры, которая явно заведена как поле текущей. В общем, как говорит Александреску, лучше один пример кода, чем 1024 слова:
#!/usr/bin/rdmd import std.stdio; struct Point2D { int x; int y; const int ndim = 2; // количество измерений void method() { writeln("Base method."); } } struct Point3D { private Point2D base; int z; const int ndim = 3; // перекрывает ndim из base void doWork() { writeln("Something important is happening here."); } void info() { writefln("x=%s, y=%s, z=%s, ndim=%s", x, y, z, ndim); writefln("base ndim=%s", base.ndim); } alias this = base; // это важное место } void main() { Point3D point; point.method(); point.doWork(); point.info(); }
Вывод программы:
$ ./subtypes.d Base method. Something important is happening here. x=0, y=0, z=0, ndim=3 base ndim=2
С классами так тоже можно делать, если очень хочется.
Ещё
-
Если объект класса имеет статические поля, их можно инициализировать в статических конструкторах:
static this(){}
; -
к методу или полю класса родителя можно обращаться через ключевое слово
super
, а черезИмяКласса.имяЧлена
можно обращаться к любому предку; -
если метод родительского класса возвращает объект класса
C
, то в переопределённом методе потомка можно возвращать не толькоC
, но и любого потомкаC
; -
объявление объекта класса с использованием ключевого слова
scope
позволяет выделить память под него на стеке; такие объекты начинают вести себя в плане жизненного цикла как структуры; -
существует альтернативный способ порождения содержимого структуры или класса на основе переданных параметров — через конструкцию
mixin template
, но это относится к теме шаблонов.
Заключение
Тема ООП весьма широка, и мы рассмотрели её здесь лишь поверхностно, чтобы в общих чертах показать различие реализации этого подхода на C++ и D. Мы не стали углубляться во множественное наследование, вложенные классы, анонимные классы, определение аллокаторов/деаллокаторов, запрет каких-нибудь неявных конструкторов, «друзья» классов C++ и т.д., иначе бы эта маленькая статейка могла бы превратиться учебник и работа тогда затянулась. В базе, что в C++, что в D нет ничего сложного, в том, чтобы разобраться со структурами и классами, но если углубляться, C++ может оказаться куда бездоннее (но и D — непростой язык). Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/827240/
Добавить комментарий