Структуры и классы в C++ и D
Реализации объектно-ориентированного программирования в разных Си-подобных языках, конечно, похожи, и все такие языки, созданные после 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++ принято использовать фигурные скобки для инициализации переменных в большинстве случаев. Но в этой истории есть много нюансов, а ад инициализации не является предметом рассмотрения данной статьи. Ко всему прочему эта тема не имеет отношения к ООП, и для большей близости кода на двух языках мы будем в дальнейшем повествовании в основном использовать синтаксические традиции 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;
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 — непростой язык). Спасибо за внимание!