dlang-book/06-классы-объектно-ориентир.../README.md

677 lines
70 KiB
Markdown
Raw Normal View History

2023-02-25 22:54:57 +00:00
# 6. Классы. Объектно-ориентированный стиль
- [6.1. Классы](#6-1-классы)
- [6.2. Имена объектов это ссылки](#6-2-имена-объектов-это-ссылки)
2023-02-25 23:51:28 +00:00
- [6.3. Жизненный цикл объекта](#6-3-жизненный-цикл-объекта)
- [6.3.1. Конструкторы](#6-3-1-конструкторы)
- [6.3.2. Делегирование конструкторов](#6-3-2-делегирование-конструкторов)
- [6.3.3. Алгоритм построения объекта](#6-3-3-алгоритм-построения-объекта)
- [6.3.4. Уничтожение объекта и освобождение памяти](#6-3-4-уничтожение-объекта-и-освобождение-памяти)
- [6.3.5. Алгоритм уничтожения объекта](#6-3-5-алгоритм-уничтожения-объекта)
- [6.3.6. Стратегия освобождения памяти](#6-3-6-стратегия-освобождения-памяти-5)
- [6.3.7. Статические конструкторы и деструкторы](#6-3-7-статические-конструкторы-и-деструкторы)
2023-02-25 22:54:57 +00:00
- [6.4. Методы и наследование]()
- [6.4.1. Терминологический «шведский стол»]()
- [6.4.2. Наследование это порождение подтипа. Статический и динамический типы]()
- [6.4.3. Переопределение только по желанию]()
- [6.4.4. Вызов переопределенных методов]()
- [6.4.5. Ковариантные возвращаемые типы]()
- [6.5. Инкапсуляция на уровне классов с помощью статических членов]()
- [6.6. Сдерживание расширяемости с помощью финальных методов]()
- [6.6.1. Финальные классы]()
- [6.7. Инкапсуляция]()
- [6.7.1. private]()
- [6.7.2. package]()
- [6.7.3. protected]()
- [6.7.4. public]()
- [6.7.5. export]()
- [6.7.6. Сколько инкапсуляции?]()
- [6.8. Основа безраздельной власти]()
- [6.8.1. string toString()]()
- [6.8.2. size_t toHash()]()
- [6.8.3. bool opEquals(Object rhs)]()
- [6.8.4. int opCmp(Object rhs)]()
- [6.8.5. static Object factory (string className)]()
- [6.9. Интерфейсы]()
- [6.9.1. Идея невиртуальных интерфейсов (NVI)]()
- [6.9.2. Защищенные примитивы]()
- [6.9.3. Избирательная реализация]()
- [6.10. Абстрактные классы]()
- [6.11. Вложенные классы]()
- [6.11.1. Вложенные классы в функциях]()
- [6.11.2. Статические вложенные классы]()
- [6.11.3. Анонимные классы]()
- [6.12. Множественное наследование]()
- [6.13. Множественное порождение подтипов]()
- [6.13.1. Переопределение методов в сценариях множественного порождения подтипов]()
- [6.14. Параметризированные классы и интерфейсы]()
- [6.14.1. И снова гетерогенная трансляция]()
- [6.15. Переопределение аллокаторов и деаллокаторов]()
- [6.16. Объекты scope]()
- [6.17. Итоги]()
С годами объектно-ориентированное программирование (ООП) из симпатичного малыша выросло в несносного прыщавого подростка, но в конце концов повзрослело и превратилось в нынешнего уравновешенного индивида. Сегодня мы гораздо лучше осознаем не только мощь, но и неизбежные ограничения объектно-ориентированной технологии. В свою очередь, это позволило сообществу программистов понять, что наиболее выгодный подход к созданию надежных проектов сочетать сильные стороны ООП и других парадигм программирования. Это довольно отчетливая тенденция: все больше современных языков программирования или включают эклектичные средства, или изначально разработаны для применения ООП в сочетании с другими парадигмами. D принадлежит к последним, и его достижения в сфере гармоничного объединения разных парадигм программирования некоторые даже считают выдающимися. В этой главе исследуются объектно-ориентированные средства D и их взаимодействие с другими средствами языка. Хорошая стартовая площадка для глубокого изучения объектно-ориентированной парадигмы классический труд Бертрана Мейера «Объектно-ориентированное конструирование программных систем» (для более формального изучения лучше подойдут «Типы в языках программирования» Пирса).
[В начало ⮍](#6-классы-объектно-ориентированный-стиль)
## 6.1. Классы
Единицей объектной инкапсуляции в D служит класс. С помощью классов можно создавать объекты, как вырезают печенье с помощью формочек. Класс может определять константы, состояния классов, состояния объектов и методы. Например:
```d
class Widget
{
// Константа
enum fudgeFactor = 0.2;
// Разделяемое неизменяемое значение
static immutable defaultName = "A Widget";
// Некоторое состояние, определенное для всех экземпляров класса Widget
string name = defaultName;
uint width, height;
// Статический метод
static double howFudgy()
{
return fudgeFactor;
}
// Метод
void changeName(string another)
{
name = another;
}
// Метод, который нельзя переопределить
final void quadrupleSize()
{
width *= 2;
height *= 2;
}
}
```
Объект типа `Widget` создается с помощью выражения `new`, результат вычисления которого сохраняется в именованном объекте: `new Widget` (см. раздел 2.3.6.1). Для обращения к идентификатору, определенному внутри класса `Widget`, расположите его после имени объекта, с которым вы хотите работать, и разделите эти два идентификатора точкой. Если член класса, к которому нужно обратиться, является статическим, перед его идентификатором достаточно указать имя класса. Например:
```d
unittest
{
// Обратиться к статическому методу класса Widget
assert(Widget.howFudgy() == 0.2);
// Создать экземпляр класса Widget
auto w = new Widget;
// Поиграть с объектом типа Widget
assert(w.name == w.defaultName); // Или Widget.defaultName
w.changeName("Мой виджет");
assert(w.name == "Мой виджет");
}
```
Обратите внимание на небольшую хитрость. В приведенном коде использовано выражение `w.defaultName`, а не `Widget.defaultName`. Для обращения к статическому члену класса всегда можно вместо имени класса использовать имя экземпляра класса. Это возможно, потому что при обработке выражения слева от точки сначала выполняется разрешение имени и только потом идентификация объекта (если потребуется). Выражение w в любом случае вычисляется: будет оно использовано или нет.
[В начало ⮍](#6-1-классы) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
## 6.2. Имена объектов это ссылки
Проведем небольшой эксперимент:
```d
import std.stdio;
class A
{
int x = 42;
}
unittest
{
auto a1 = new A;
assert(a1.x == 42);
auto a2 = a1;
a2.x = 100;
assert(a1.x == 100);
}
```
Этот эксперимент завершается успешно (все проверки пройдены), а значит, `a1` и `a2` не являются разными объектами: изменение объекта `a2` действительно отразилось и на ранее созданном объекте `a1`. Эти две переменные всего лишь два разных имени одного и того же объекта, следовательно, изменение `a2` влияет на `a1`. Инструкция `auto a2 = a1;` не создает новый объект типа `A`, а только дает существующему объекту еще одно имя (рис. 6.1).
![image-6-2-1](images/image-6-2-1.png)
***Рис. 6.1.*** *Инструкция `auto a2 = a1` только вводит дополнительное имя для того же внутреннего объекта*
Такое поведение соответствует принципу: все экземпляры класса являются *сущностями*, то есть обладают «индивидуальностью» и не предполагают копирования без серьезных причин. Экземпляры значения (например, встроенные числа), напротив, характеризуются полным копированием; новый тип-значение определяется с помощью структуры (см. главу 7).
Итак, в мире классов сначала нам встречаются *объекты* (*экземпляры класса*), а затем *ссылки* на них. Воображаемые стрелки, присоединяющие ссылки к объектам, называются *привязками* (*bindings*); мы, например, говорим, что идентификаторы `a1` и `a2` привязаны к одному и тому же объекту, другими словами, имеют одну и ту же привязку. С объектами можно работать только через ссылки на них. Получив при создании место в памяти, объект остается там навсегда (по крайней мере до тех пор, пока он вам нужен). Если вам надоест какой-то объект, просто привяжите его ссылку к другому объекту. Например, если нужно, чтобы две ссылки обменялись привязками:
```d
unittest
{
auto a1 = new A;
auto a2 = new A;
a1.x = 100;
a2.x = 200;
// Заставим a1 и a2 обменяться привязками
auto t = a1;
a1 = a2;
a2 = t;
assert(a1.x == 200);
assert(a2.x == 100);
}
```
Вместо трех последних строк можно было бы использовать универсальную вспомогательную функцию `swap` из модуля `std.algorithm`: `swap(a1, a2)`, но явная запись процесса обмена нагляднее. На рис. 6.2 продемонстрированы привязки до и после обмена.
Сами объекты остаются на том же месте, то есть после создания они никогда не перемещаются в памяти. Просто замечательно, объект никогда не исчезнет: можно рассчитывать, что объект навсегда останется там, куда он был помещен при создании. (Сборщик мусора перерабатывает в фоновом режиме те объекты, которые больше не используются.) Ссылки на объекты (в данном случае `a1` и `a2`) можно заставить «смотреть в другую сторону», переназначив их привязку. Когда библиотека времени исполнения обнаруживает, что для какого-то объекта больше нет привязанных к нему ссылок, она может заново использовать выделенную под него память (этот процесс называется сбором мусора).[^1] Такое поведение
![image-6-2-2](images/image-6-2-2.png)
***Рис. 6.2.*** *Привязки до и после обмена. В процессе обмена меняются привязки к ссылкам; сами объекты остаются на том же месте*
в корне отличается от семантики *значения* (например, `int`), в случае которого нет никаких косвенных изменений или привязок: каждое имя прочно закреплено за значением, которым манипулируют с помощью этого идентификатора.
Ссылка, не привязанная к какому-либо объекту, это «пустая» ссылка (`null`). При инициализации по умолчанию с помощью свойства `.init` ссылки на классы получают значение `null`. Можно сравнивать ссылку с константой `null` и присваивать ссылке значение `null`. Следующие проверки пройдут успешно:
```d
unittest
{
A a;
assert(a is null);
a = new A;
assert(a !is null);
a = null;
assert(a is null);
a = A.init;
assert(a is null);
}
```
Обращение к элементу непривязанной («пустой», `null`) ссылки ведет к аппаратной ошибке, экстренно останавливающей приложение (или на некоторых системах и при некоторых обстоятельствах запускающей отладчик). Если вы попытаетесь осуществить доступ к нестатическому элементу ссылки и компилятор может статически доказать, что эта ссылка в любом случае в этот момент окажется пустой, он откажется компилировать код.
```d
A a;
a.x = 5; // Ошибка! Ссылка a пуста!
```
Иногда компилятор ведет себя сдержанно, стараясь не слишком надоедать вам: если ссылка только *может* быть пустой (но не всегда будет таковой), коду дается «зеленый свет» и все разговоры об ошибках откладываются до времени исполнения программы. Например:
```d
A a;
if (‹условие›)
{
a = new A;
}
...
if (‹условие›)
{
a.x = 43; // Все в порядке
}
```
Компилятор «пропускает» такой код, даже несмотря на то, что между двумя вычислениями `‹условие›` может изменить значение. В общем случае было бы непросто проверить, насколько корректна инициализация объекта, так что компилятор решает, что вы сами знаете, что делаете (кроме самых простых случаев, когда он уверен, что вы пытаетесь использовать пустую ссылку неподобающим образом).
В языке D применяется такой же основанный на ссылочной семантике подход к обработке объектов классов, как и во многих других объектно-ориентированных языках. Использование для объектов классов ссылочной семантики и сбора мусора имеют как положительные, так и отрицательные следствия, включая следующие:
2023-02-25 23:51:28 +00:00
- **+ Полиморфизм**. Уровень косвенности, достигаемый благодаря последовательному использованию ссылок, делает возможной поддержку полиморфизма. Все ссылки обладают одинаковым размером, а ассоциированные с ними объекты могут иметь разные размеры, даже если имеют якобы один и тот же тип (что осуществляется через наследование, о котором речь пойдет очень скоро). Поскольку ссылки обладают одним и тем же размером независимо от размера объектов, на которые они ссылаются, вы всегда можете использовать вместо ссылок на объекты классов-потомков ссылки на объекты родительских классов. Кроме того, как следует работают массивы объектов даже когда объекты в массиве обладают разными размерами. Если вы имели дело с C++, вам, конечно же, известно о необходимости использования указателей для организации полиморфизма и о разнообразных летальных проблемах, с которыми сталкивается программист, если забывает об этом.
- **+ Безопасность**. Многие воспринимают сбор мусора только как удобное средство, которое облегчает процесс кодирования, освобождая программиста от обязанности управлять памятью. Возможно, это прозвучит неожиданно, но модель вечной жизни (которая воплощается благодаря сбору мусора) и безопасность памяти прочно связаны. Там, где жизнь вечна, нет «висячих» ссылок, то есть ссылок на некоторый переставший существовать объект, память которого была заново использована отдана в распоряжение совершенно постороннего объекта. Заметим, что той же степени безопасности можно добиться, везде используя семантику значения (команда `auto a2 = a1` дублирует экземпляр класса `A`, на который ссылается `a1`, и привязывает `a2` к копии). Такой подход, однако, вряд ли интересен, поскольку лишает возможности создавать какие-либо ссылочные структуры данных (такие как списки, графы и вообще любые разделяемые ресурсы).
- ** Цена выделения памяти**. В общем случае классы должны располагаться в куче, подлежащей сбору мусора, что обычно медленнее работает и съедает больше памяти, чем при размещении в стеке. В последнее время разница сильно уменьшилась, но она все же есть.
- ** Связанность идентификаторов, определенных далеко друг от друга**. Основной риск при использовании ссылок неумеренное порождение псевдонимов. При повсеместном применении ссылочной семантики очень просто получить ссылки на один и тот же объект в разных и самых неожиданных местах. Переменные `a1` и `a2` на рис. 6.1 могут находиться сколь угодно далеко друг от друга, т. к. по логике приложения кроме них у того же объекта может быть множество других, висячих ссылок. Любопытно, но если объект неизменяем, проблема исчезает: пока никто не изменяет объект, нет и связанности. Сложности возникают, когда некоторое изменение, имевшее место в некотором контексте, неожиданно и драматично повлияет на состояние (как это видится из другой части приложения). Один из способов улучшить такое положение дел заключается в постоянном явном дублировании, которое обычно осуществляется с помощью специального метода `clone`. Минусы этой техники: она зависит от дисциплинированности человека, и такой образ действий может снизить скорость работы приложения, если некоторые его части решат консервативно клонировать объекты из принципа «как бы чего не вышло».
2023-02-25 22:54:57 +00:00
Сравним ссылочную семантику с семантикой значений а-ля `int`. У семантики значений есть свои преимущества, среди которых выделяется логический вывод: в выражениях всегда можно заменять равные значения друг на друга, при этом результат не изменяется. (А к ссылкам, использующим для изменения состояния объектов вызовы методов, такой подход неприменим.) Другое важное преимущество семантики значений скорость. Но даже если вы воспользуетесь динамической щедростью полиморфизма, от ссылочной семантики никуда не деться. Некоторые языки пытались предоставить возможность использовать и ту, и другую семантику и заслужили прозвище «нечистых» (в противоположность чисто объектно-ориентированным языкам, использующим ссылочную семантику унифицированно для всех типов). D нечист и очень гордится этим. Во время разработки необходимо принять решение: если вы желаете работать с некоторым типом в рамках объектно-ориентированной парадигмы, следует выбрать тип `class`; иначе придется использовать тип `struct` и поступиться всеми удобствами ООП, присущими ссылочной семантике.
[В начало ⮍](#6-2-имена-объектов-это-ссылки) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
2023-02-25 23:51:28 +00:00
## 6.3. Жизненный цикл объекта
Теперь, когда мы получили общее представление о местонахождении объекта, подробно изучим его жизненный цикл. Объект создается с помощью выражения `new`:
```d
import std.math;
class Test
{
double a = 0.4;
double b;
}
unittest
{
// Объект создается с помощью выражения new
auto t = new Test;
assert(t.a == 0.4 && isNaN(t.b));
}
```
При вычислении выражения `new Test` конструируется объект типа `Test` с состоянием по умолчанию, то есть экземпляр класса `Test`, каждое из полей которого инициализировано своим значением по умолчанию. Любой тип `T` обладает статически известным значением по умолчанию, обратиться к которому можно через свойство `T.init` (значения свойств `.init` для базовых типов приведены в табл. 2.1). Если вы хотите инициализировать некоторые поля значениями, отличными от соответствующих значений свойства `.init`, укажите при определении этих полей статически известные инициализирующие значения, как показано в предыдущем примере для поля `a`. Выполнение теста модуля при этом не порождает исключений, так как это поле явно инициализируется константой `0.4`, а поле `b` не трогали, а значит, оно неявно инициализируется значением выражения `double.init`, то есть NaN («нечисло»).
[В начало ⮍](#6-3-жизненный-цикл-объекта) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
### 6.3.1. Конструкторы
Разумеется, в большинстве случаев бывает недостаточно инициализировать поля лишь статически известными значениями. Выполнить при создании объекта некоторый код позволяет специальная функция *конструктор*. Конструктор это функция с именем `this` и без объявления возвращаемого типа.
```d
class Test
{
double a = 0.4;
int b;
this(int b)
{
this.b = b;
}
}
unittest
{
auto t = new Test(5);
}
```
Если класс определяет хотя бы один конструктор, то неявный конструктор становится недоступным. С классом `Test`, определенным выше, инструкция
```d
auto t = new Test;
```
уже не работает. Цель такого запрета помочь избежать типичной ошибки: разработчик заботливо определяет ряд конструкторов с параметрами, но совершенно забывает о конструкторе по умолчанию. Как обычно в D такую защиту от забывчивости легко обойти: достаточно показать компилятору, что вы обо всем помните:
```d
class Test
{
double a = 0.4;
int b;
this(int b)
{
this.b = b;
}
this() {} // Конструктор по умолчанию,
// все поля инициализируются неявно
}
```
Внутри метода (кроме статических методов, см. раздел 6.5) ссылка `this` неявно привязывается к объекту-адресату вызова. Иногда (как в предыдущем примере, иллюстрирующем общепринятое соглашение об именовании внутри конструкторов) эта ссылка может оказаться полезной: если с помощью параметра `a` предполагается инициализировать член класса, следует назвать параметр так же, как и член класса, а обращение к последнему уточнить явной ссылкой на объект в виде ключевого слова `this`, избегая таким образом двусмысленности при обращении к параметру и члену класса.
Несмотря на то что можно изменить свойство `this.field` для любого поля `field`, нельзя переназначить привязку самой ссылки `this`, которая всегда воспринимается компилятором как r-значение:
```d
class NoGo
{
void fun()
{
// Просто привяжем this к другому объекту
this = new NoGo; // Ошибка! Нельзя изменять this!
}
}
```
Обычные правила перегрузки функций (раздел 5.5) применимы и к конструкторам: класс может определять любое количество конструкторов, но каждый из них должен обладать уникальной сигнатурой (отличающейся числом или типом параметров, хотя бы на один параметр).
[В начало ⮍](#6-3-1-конструкторы) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
### 6.3.2. Делегирование конструкторов
Рассмотрим класс `Widget`, определяющий два конструктора:
```d
class Widget
{
this(uint height)
{
this.width = 1;
this.height = height;
}
this(uint width, uint height)
{
this.width = width;
this.height = height;
}
uint width, height;
...
}
```
В этом коде много повторений, что лишь усугубилось бы в случае классов большего размера, но, к счастью, один конструктор может положиться на другой:
```d
class Widget
{
this(uint height)
{
this(1, height); // Положиться на другой конструктор
}
this(uint width, uint height)
{
this.width = width;
this.height = height;
}
uint width, height;
...
}
```
Однако с вызовом конструкторов а-ля `this(1, h)` связан ряд ограничений. Во-первых, такой вызов возможен только из другого конструктора. Во-вторых, если вы решили сделать такой вызов, то обязаны убедить компилятор, что в вашем конструкторе ровно один такой вызов, несмотря ни на что. Например, следующий конструктор некорректен:
```d
this(uint h)
{
if (h > 1)
{
this(1, h);
}
// Ошибка! При невыполнении условия конструктор будет пропущен
}
```
В этой ситуации компилятор выяснит, что возможны случаи, когда другой конструктор не будет вызван, и интерпретирует это как ошибку. Смысл такого ограничения в четком разграничении двух альтернатив: конструктор или создает и инициализирует объект сам, или делегирует выполнение этой задачи другому конструктору. Варианты, когда неясно, как поступит конструктор (решит действовать самостоятельно или переложит работу на другого), бракуются.
Дважды вызывать один и тот же конструктор также некорректно:
```d
this(uint h)
{
if (h > 1)
{
this(1, h);
}
this(0, h);
}
```
Дважды инициализировать объект еще хуже, чем забыть его инициализировать, так что в этом случае также диагностируется ошибка. В двух словах, конструктору разрешается вызвать другой конструктор или ровно ноль, или ровно один раз. Соблюдение этого правила проверяется во время компиляции с помощью простого анализа потока управления.
[В начало ⮍](#6-3-2-делегирование-конструкторов) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
### 6.3.3. Алгоритм построения объекта
Во всех языках построение объекта не очень простой процесс. Все начинается с получения участка нетипизированной памяти, которая по мере наполнения информацией начинает выглядеть и вести себя как экземпляр класса. И тут никак не обойтись без магии.
В языке D построение объекта включает следующие шаги:
1. *Выделение памяти*. Библиотека времени исполнения выделяет участок «сырой» памяти в куче, достаточный для размещения нестатических полей объекта. Память подо все объекты, основанные на классах, выделяется динамически в отличие от C++, в D нет способа выделить для объекта память в стеке[^2]. Если выделить память не удалось, построение объекта прерывается: порождается исключительная ситуация.
*Инициализация полей*. Каждое поле инициализируется своим значением по умолчанию. Как уже говорилось, в качестве значения поля по умолчанию выступает значение, указанное при объявлении поля в виде `= значение`, или при отсутствии такой записи значение свойства `.init` типа поля.
2. *Брендирование*. После завершения инициализации полей значениями по умолчанию объекту присваивается статус полноправного экземпляра класса `T` (объект брендируется) еще *до* того, как будет вызван настоящий конструктор.
3. *Вызов конструктора*. Наконец, компилятор инициирует вызов подходящего конструктора. Если класс не определяет собственный конструктор, этот шаг пропускается.
Поскольку объект считается «живым» и правильно построенным сразу после инициализации по умолчанию, настоятельно рекомендуется использовать инициализирующие значения, которые всегда приводят объект в осмысленное состояние. Настоящий конструктор внесет затем свои поправки, приведя объект в другое интересное состояние (разумеется, также осмысленное).
Если ваш конструктор заново присваивает значения некоторым полям, то двойное присваивание не должно быть причиной проблем с быстродействием. В большинстве случаев, если тело конструктора достаточно простое, компилятору хватает ума понять, что первое присваивание лишнее, и применить механизм оптимизации с мрачным названием «уничтожение мертвых присваиваний» (dead assignment elimination).
Если важнее всего эффективность, то в качестве инициализирующего значения поля можно указать ключевое слово `void`; в этом случае нужно очень внимательно проследить за местонахождением инициализирующего присваивания: оно должно быть внутри конструктора[^3]. Возможно, вам покажется удобным использовать `= void` с массивами фиксированной длины. Оптимизация двойной инициализации всех элементов массива очень сложная задача для компилятора, и вы можете ему помочь. Следующий код эффективно инициализирует массив фиксированного размера значениями `0.0`, `0.1`, `0.2`, ..., `12.7`.
```d
class Transmogrifier
{
double[128] alpha = void;
this()
{
foreach (i, ref e; alpha)
{
e = i * 0.1;
}
}
...
}
```
Иногда некоторые поля намеренно оставляют неинициализированными. Например, экземпляр класса `Transmogrifier` может отслеживать уже задействованную часть массива `alpha` с помощью переменной `usedAlpha`, изначально равной нулю. Таким образом, составные части объекта будут знать, что на самом деле инициализирована только часть массива, а именно элементы с индексами от `0` до `usedAlpha - 1`:
```d
class Transmogrifier
{
double[128] alpha = void;
size_t usedAlpha;
this()
{
// Оставить переменную usedAlpha равной 0, а массив alpha неинициализированным
}
...
}
```
Изначально переменная `usedAlpha` равна нулю, этого достаточно для инициализации объекта класса `Transmogrifier`. По мере роста `usedAlpha` код не должен читать элементы в интервале `alpha[usedAlpha .. $]`, а только присваивать им значения. Разумеется, за этим должны следить вы, а не компилятор (вот пример того, что порой эффективность неизбежно связана с проверяемостью на этапе компиляции). Хотя такая оптимизация обычно незначительна, иногда лишние принудительные инициализации могут существенно отразиться на общих результатах, и полезно иметь запасной выход.
[В начало ⮍](#6-3-3-алгоритм-построения-объекта) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
### 6.3.4. Уничтожение объекта и освобождение памяти
Для всех объектов классов D поддерживает кучу, пополняемую благодаря сбору мусора. Можно считать, что сразу же после выделения памяти под объект он становится вечным (в пределах времени работы самого приложения). Сборщик мусора перерабатывает память, используемую объектом, только если убежден, что больше нет доступных ссылок на этот объект. Такой подход способствует созданию чистого и безопасного кода, основанного на классах.
Для некоторых классов важно иметь зацепку в процессе уничтожения, чтобы освобождать возможно задействованные ими дополнительные ресурсы. Такие классы могут определять *деструктор*, задаваемый как специальная функция с именем `~this`:
```d
import core.stdc.stdlib;
class Buffer
{
private void* data;
// Конструктор
this()
{
data = malloc(1024);
}
// Деструктор
~this()
{
free(data);
}
...
}
```
Этот пример иллюстрирует экстремальную ситуацию класс, который самостоятельно обслуживает собственный буфер «сырой» памяти. В большинстве случаев класс использует должным образом инкапсулированные ресурсы, так что определять деструкторы вовсе не обязательно.
[В начало ⮍](#6-3-4-уничтожение-объекта-и-освобождение-памяти) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
### 6.3.5. Алгоритм уничтожения объекта
Уничтожение объекта, как и его построение, происходит по определенному алгоритму:
1. Сразу же после брендирования (шаг 3 построения) объект считается «живым» и попадает под наблюдение сборщика мусора. Обратите внимание: это происходит, даже если позже при выполнении определенного пользователем конструктора возникнет исключение. Учитывая, что инициализация по умолчанию и брендирование всегда выполняются успешно, можно сделать вывод, что объект, которому успешно выделена память, с точки зрения сборщика мусора выглядит как полноценный объект.
2. Объект используется в любом месте программы.
3. Все доступные ссылки на объект исчезли; объект больше недоступен никакому коду.
4. В некоторый момент (зависит от реализации) система осознает, что память объекта может быть переработана, и вызывает деструктор.
5. Спустя еще некоторое время (сразу после вызова деструктора или когда-нибудь позже) система заново использует память объекта.
Важная деталь, имеющая отношение к двум последним шагам: сборщик мусора гарантирует, что деструктор объекта никогда не сможет обратиться к объекту, память которого уже освобождена. Можно обратиться к только что уничтоженному объекту, но не объекту, память которого только что освобождена. В языке D уничтоженные объекты «удерживают» память в течение небольшого промежутка времени пока не уничтожены связанные с ними объекты. Иначе была бы невозможна безопасная реализация уничтожения и освобождения памяти объектов, ссылающихся друг на друга по кругу (например, кольцевого списка).
Описанный жизненный цикл может варьироваться по разным причинам. Во-первых, очень вероятно, что выполнение приложения завершится еще до достижения даже шага 4, как бывает с маленькими приложениями в системах с достаточным количеством памяти. В таком случае D предполагает, что завершающееся приложение *де-факто* освободит все ресурсы, ассоциированные с ним, так что никакого деструктора язык не вызывает.
Другой способ вмешаться в естественный жизненный цикл объекта явный вызов деструктора, например с помощью функции `clear` из модуля `object` (модуль стандартной библиотеки, автоматически подключаемый во время каждой компиляции).
```d
unittest
{
auto b = new Buffer;
...
clear(b); // Избавиться от дополнительного состояния b
... // Здесь все еще можно использовать b
}
```
Вызовом `clear(b)` пользователь выражает желание явно вызвать деструктор `b` (если таковой имеется), стереть состояние этого объекта до `Buffer.init` и установить указатель на таблицу виртуальных функций в `null`, после чего при попытке вызвать метод этого объекта будет сгенерирована ошибка времени исполнения. Тем не менее, в отличие от аналогичной функции в C++, функция `clear` не освобождает память объекта, а в D отсутствует оператор `delete`. (Раньше в D был оператор `delete`, но он уже не используется и считается устаревшим.) Вы все равно можете освободить память, вызвав функцию `GC.free()` из модуля `core.memory`, если действительно, *действительно* знаете, что делаете. В отличие от освобождения памяти, вызывать функцию `clear` безопасно, поскольку в этом случае все данные остаются на месте и нет угрозы появления «висячих» указателей. После выполнения инструкции `clear(obj);` можно, как и раньше, обращаться к объекту `obj` и использовать его для любых целей, даже если он уже не обладает никаким особенным состоянием. Например, следующий код D считает корректным[^4]:
```d
unittest
{
auto b = new Buffer;
auto b1 = b; // Дополнительный псевдоним для b
clear(b);
assert(b1.data !is null); // Дополнительный псевдоним все еще ссылается на (корректный) "скелет" b
}
```
Таким образом, после вызова функции `clear` объект остается «живым» и пригодным для использования, но его деструктор уже вызван, а состояние объекта соответствует состоянию, которое экземпляры этого класса приобретают после вызова конструктора. Любопытно, что в процессе следующего сбора мусора деструктор объекта будет вызван *снова*. Это происходит, потому что сборщик мусора, естественно, и понятия не имеет о том, в каком состоянии вы оставили объект.
Почему была выбрана такая модель поведения? Ответ прост: благодаря разделению уничтожения объекта и освобождения памяти вы получаете возможность вручную контролировать «дорогие» ресурсы, которые могут находиться в ведении объекта (такие как файлы, сокеты, мьютексы и системные дескрипторы), и одновременно гарантии безопасности памяти. Использование `new` и `clear` предохраняет вас от создания висячих указателей. (Встреча с которыми реально угрожает тому, кто якшается с функциями `malloc` и `free` из C или с той же функцией `GC.free`.) В общем случае имеет смысл разделять освобождение ресурсов (безопасно) и переработку памяти (небезопасно). Память в корне отличается от всех других ресурсов, поскольку она представляет собой физическую основу для системы типов. Случайно перераспределив ее, вы рискуете подорвать любые гарантии, которые только может дать система типов.
[В начало ⮍](#6-3-5-алгоритм-уничтожения-объекта) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
### 6.3.6. Стратегия освобождения памяти[^5]
На всем протяжении данной главы предлагается, иногда весьма навязчиво, одна стратегия освобождения памяти использование сборщика мусора. Эта стратегия необычайно удобна, но имеет серьезный недостаток нерациональное использование памяти. Рассмотрим работу сборщика мусора.
Когда сборщик мусора выделяет некоторую область памяти, он запоминает указатель на эту область и ее размер и начинает вести учет ссылок на данную область. Когда не остается ни одной ссылки на эту область, эта область становится вакантной для освобождения. Но освобождение происходит не сразу.
Если в какой-то момент для выделения очередного блока памяти сборщику мусора не хватает собственного пула памяти, он запускает процедуру сбора мусора, надеясь освободить достаточно памяти для выделения блока запрошенного размера. Если после сбора мусора памяти пула по-прежнему не хватает, сборщик мусора запрашивает память у операционной системы. Процесс сбора мусора сопровождается приостановкой всех потоков выполнения и занимает сравнительно продолжительное время. Впрочем, можно инициировать внеочередной сбор мусора, вызвав функцию `core.memory.GC.collect()`.
2023-02-25 22:54:57 +00:00
2023-02-25 23:51:28 +00:00
Функция `core.memory.GC.disable()` запрещает автоматический вызов процесса сбора мусора, `core.memory.GC.enable()` разрешает его. Если функция `disable` была вызвана несколько раз, то функция `enable` должна быть вызвана как минимум столько же раз для разрешения автоматического сбора мусора.
2023-02-25 22:54:57 +00:00
2023-02-25 23:51:28 +00:00
Функция `core.memory.GC.minimize()` возвращает лишнюю память операционной системе.
2023-02-25 22:54:57 +00:00
2023-02-25 23:51:28 +00:00
Может возникнуть соблазн уничтожать объекты вручную, оставляя сборщику мусора наиболее сложные ситуации, когда отследить жизнь объекта проблематично.
Рассмотрим пример:
```d
import std.c.stdlib;
class Storage
{
private SomeClass sub_obj_1;
private SomeClass sub_obj_2;
private void* data;
this()
{
sub_obj_1 = new SomeClass;
sub_obj_2 = new SomeClass;
data = malloc(4096);
}
~this()
{
free(data);
// Блок data выделен функцией malloc и не учтен сборщиком мусора,
// а значит, должен быть уничтожен вручную.
}
}
...
Storage obj = new Storage; // Создали объект Storage
...
delete obj; // Уничтожили объект
```
Объект `obj` действительно уничтожен. Что же стало с внутренними объектами `obj`? Они остались целы, и сборщик мусора их рано или поздно уничтожит. Но предположим, мы уверены, что кроме как в объекте `obj` ссылок на эти объекты нет, и логично уничтожить их вместе с `obj`.
Что ж, изменим соответствующим образом деструктор:
```d
~this()
{
delete sub_obj_1;
delete sub_obj_2;
free(data);
}
```
Приведенный пример теперь отработает как надо. Но что если объект `obj` не уничтожать вручную, а оставить на откуп сборщику мусора? В этой ситуации никто не гарантирует, что деструктор `obj` будет запущен до уничтожения `sub_obj_1`. Если все произойдет наоборот, то деструктор `obj` попытается уничтожить уничтоженный объект, что вызовет аварийное завершение программы.
Значит, вызывать деструктирующие функции в деструкторе следует только в том случае, если этот деструктор вызывается вручную, а не сборщиком мусора.
Как определить, кто вызывает деструктор? Для этого достаточно определить собственный деаллокатор (см. раздел 6.15) (если, конечно, ваша реализация компилятора предоставляет такую возможность).
```d
class Foo
{
char[] arr;
void* buffer;
this()
{
arr = new char[500];
buffer = std.c.malloc(500);
}
~this()
{
std.c.free(buffer);
}
private void dispose()
{
delete arr;
}
delete(void* v)
{
(cast(F)v).dispose();
core.memory.GC.free(v);
}
}
```
В данном случае мы переопределяем поведение оператора `delete`, сообщая ему, что перед непосредственным освобождением памяти следует вызвать метод `dispose`. В случае уничтожения объекта сборщиком мусора метод `dispose` вызван не будет.
[В начало ⮍](#6-3-6-стратегия-освобождения-памяти-5) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
### 6.3.7. Статические конструкторы и деструкторы
Внутри классов, как и повсюду в D, статические данные (объявляемые с ключевым словом `static`) должны всегда инициализироваться константами, известными во время компиляции. Чтобы предоставить легальное средство выполнения кода во время запуска потока, компилятор позволяет определять специальную функцию `static this()`. Код инициализации на уровне модуля и на уровне класса объединяется, и библиотека поддержки времени исполнения обрабатывает статическую инициализацию в заданном порядке.
Внутри класса можно определить один или несколько статических конструкторов по умолчанию:
```d
class A
{
static A a1;
static this()
{
a1 = new A;
}
...
static this()
{
a2 = new A;
}
static A a2;
}
```
Такая функция называется *статическим конструктором класса*. Во время загрузки приложения перед выполнением функции `main` (а в многопоточном приложении в момент создания нового потока) runtime-библиотека последовательно выполняет все статические конструкторы в том порядке, в каком они объявлены в исходном коде. В предыдущем примере поле `a1` будет инициализировано раньше, чем поле `a2`. Порядок выполнения статических конструкторов различных классов внутри одного модуля тоже определяется лексическим порядком. Статические конструкторы в модулях, не имеющих отношения друг к другу, выполняются в произвольном порядке. Наконец, самое интересное: статические конструкторы классов в модулях, взаимно зависящих друг от друга, упорядочиваются так, чтобы исключить возможность использования класса до выполнения его статического конструктора.
Инициализации упорядочиваются так: допустим, класс `A` определен в модуле `MA`, а класс `B` в модуле `MB`. Тогда возможны следующие ситуации:
- только один из классов `A` и `B` определяет статический конструктор здесь не нужно беспокоиться ни о каком упорядочивании;
- ни один из модулей `MA` и `MB` не включает другой модуль (`MB` и `MA` соответственно) последовательность инициализации классов не определяется (сработает любой порядок, поскольку модули не зависят друг от друга);
- модуль `MA` включает модуль `MB` статический конструктор `B` выполняется перед статическим конструктором `A`;
- модуль `MB` включает модуль `MA` статический конструктор `A` выполняется перед статическим конструктором `B`;
- модуль `MA` включает модуль `MB` и модуль `MB` включает модуль `MA` диагностируется ошибка «циклическая зависимость», и выполнение прерывается на этапе загрузки программы.
Эта цепь рассуждений на самом деле больше зависит не от классов `A` и `B`, а от самих модулей и отношений включения между ними. Подробно эти темы обсуждаются в главе 11.
Если при выполнении статического конструктора возникает исключение, выполнение программы также прерывается. Если же все проходит успешно, классам также дается возможность убрать за собой во время останова потока с помощью деструкторов, которые, как и можно было предположить, выглядят так:
```d
class A
{
static A a1;
static ~this()
{
clear(a1);
}
...
static A a2;
static ~this()
{
clear(a2);
}
}
```
Статические деструкторы запускаются в процессе останова потока. Для каждого модуля они выполняются в порядке, *обратном* порядку их определения. В примере выше деструктор `a2` будет вызван до вызова деструктора `a1`. При участии нескольких модулей порядок вызова статических деструкторов, определенных в этих модулях, соответствует порядку, обратному тому, в котором этим модулям давался шанс вызвать свои статические конструкторы. «Бесконечная башня из черепах»[^6] наоборот.
[В начало ⮍](#6-3-7-статические-конструкторы-и-деструкторы) [Наверх ⮍](#6-классы-объектно-ориентированный-стиль)
[^1]: Язык D также предоставляет возможность «ручного» управления памятью (manual memory management) и на данный момент позволяет принудительно уничтожать объекты с помощью оператора delete: `delete obj;`, при этом значение ссылки `obj` будет установлено в `null` (см. ниже), а память, выделенная под объект, будет освобождена. Если `obj` уже содержит `null`, ничего не произойдет. Однако следует соблюдать осторожность: повторное уничтожение одного объекта или обращение к удаленному объекту по другой ссылке приведет к катастрофическим последствиям (сбои и порча данных в памяти, источники которых порой очень трудно обнаружить), и эта опасность усугубляет необходимость в сборщике мусора. Из-за этих рисков оператор `delete` планируют убрать из самого языка, оставив в виде функции в стандартной библиотеке. Но при этом ручное управление памятью позволяет более эффективно ее использовать. Вердикт: задействуйте эту возможность, если уверены, что на момент вызова `delete` объект `obj` точно не удален и `obj` последняя ссылка на данный объект, и не удивляйтесь, если в один прекрасный день `delete` исчезнет из реализаций языка. *Прим. науч. ред.*
[^2]: На данный момент реализации D предоставляют средства выделения памяти под классы в стеке (с помощью класса памяти `scope`) или вообще в любом фрагменте памяти (с помощью классовых аллокаторов и деаллокаторов). Но поскольку эти возможности небезопасны, они могут быть удалены из языка, так что не рассчитывайте на их вечное существование. *Прим. науч. ред.*
[^3]: В текущих реализациях использование `void` не влияет на производительность, так как все поля класса инициализируются «одним махом» копированием памяти из `.init` для экземпляров класса. *Прим. науч. ред.*
[^4]: В соответствии с предыдущим примечанием сегодня этот тест не пройдет. *Прим. науч. ред.*
[^5]: Описание этой части языка намеренно не было включено в оригинал книги, но поскольку эта возможность имеется в языке, мы включили ее описание в перевод. *Прим. науч. ред.*
[^6]: Образ из книги «Краткая история времени» Стивена Хокинга: Вселенная как плоский мир, стоящий на спине гигантской черепахи, «та на другой черепахе, та тоже на черепахе, и так все ниже и ниже». *Прим. ред.*