Реализации объектно-ориентированного программирования в разных Си-подобных языках, конечно, похожи, и все такие языки, созданные после 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 — непростой язык). Спасибо за внимание!