В главах 3 и 4 было исследовано несколько основных синтаксических конструкций, присущих любому приложению .NET Core, которое вам придется разрабатывать. Начиная с данной главы, мы приступаем к изучению объектно-ориентированных возможностей языка С#. Первым, что вам предстоит узнать, будет процесс построения четко определенных типов классов, которые поддерживают любое количество конструкторов. После введения в основы определения классов и размещения объектов остаток главы будет посвящен теме инкапсуляции. В ходе изложения вы научитесь определять свойства классов, а также ознакомитесь с подробными сведениями о ключевом слове
static
, синтаксисе инициализации объектов, полях только для чтения, константных данных и частичных классах.
С точки зрения платформы .NET Core наиболее фундаментальной программной конструкцией является тип класса. Формально класс — это определяемый пользователем тип, состоящий из полей данных (часто называемых переменными-членами) и членов, которые оперируют полями данных (к ним относятся конструкторы, свойства, методы, события и т.д.). Коллективно набор полей данных представляет "состояние" экземпляра класса (также известного как объект). Мощь объектно-ориентированных языков, таких как С#, заключается в том, что за счет группирования данных и связанной с ними функциональности в унифицированное определение класса вы получаете возможность моделировать свое программное обеспечение в соответствии с сущностями реального мира.
Для начала создайте новый проект консольного приложения C# по имени
SimpleClassExample
. Затем добавьте в проект новый файл класса (Car.cs
). Поместите в файл Car.cs
оператор using
и определите пространство имен, как показано ниже:
using System;
namespace SimpleClassExample
{
}
На заметку! В приводимых далее примерах определять пространство имен строго обязательно. Однако рекомендуется выработать привычку использовать пространства имен во всем коде, который вы будете писать. Пространства имен подробно обсуждались в главе 1.
Класс определяется в C# с применением ключевого слова
class
. Вот как выглядит простейшее объявление класса (позаботьтесь о том, чтобы объявление класса находилось внутри пространства имен SimpleClassExample
):
class Car
{
}
После определения типа класса необходимо определить набор переменных-членов, которые будут использоваться для представления его состояния. Например, вы можете принять решение, что объекты
Car
(автомобили) должны иметь поле данных типа int
, представляющее текущую скорость, и поле данных типа string
для представления дружественного названия автомобиля. С учетом таких начальных проектных положений класс Car
будет выглядеть следующим образом:
class Car
{
// 'Состояние' объекта Car.
public string petName;
public int currSpeed;
}
Обратите внимание, что переменные-члены объявлены с применением модификатора доступа
public
. Открытые (public
) члены класса доступны напрямую после того, как создан объект этого типа. Вспомните, что термин объект используется для описания экземпляра заданного типа класса, который создан с помощью ключевого слова new
.
На заметку! Поля данных класса редко (если вообще когда-нибудь) должны определяться как открытые. Чтобы обеспечить целостность данных состояния, намного лучше объявлять данные закрытыми (
private
) или возможно защищенными (protected
) и разрешать контролируемый доступ к данным через свойства (как будет показано далее в главе). Тем не менее, для максимального упрощения первого примера мы определили поля данных как открытые.
После определения набора переменных-членов, представляющих состояние класса, следующим шагом в проектировании будет установка членов, которые моделируют его поведение. Для этого примера в классе
Car
определены методы по имени SpeedUp()
и PrintState()
. Модифицируйте код класса Car
следующим образом:
class Car
{
// 'Состояние' объекта Car.
public string petName;
public int currSpeed;
// Функциональность Car.
// Использовать синтаксис членов, сжатых до выражений,
// который рассматривался в главе 4.
public void PrintState()
=> Console.WriteLine("{0} is going {1} MPH.", petName, currSpeed);
public void SpeedUp(int delta)
=> currSpeed += delta;
}
Метод
PrintState()
— простая диагностическая функция, которая выводит текущее состояние объекта Car
в окно командной строки. Метод SpeedUp()
увеличивает скорость автомобиля, представляемого объектом Car
, на величину, которая передается во входном параметре типа int
. Обновите операторы верхнего уровня в файле Program.cs
, как показано ниже:
Console.WriteLine("***** Fun with Class Types *****\n");
// Разместить в памяти и сконфигурировать объект Car.
Car myCar = new Car();
myCar.petName = "Henry";
myCar.currSpeed = 10;
// Увеличить скорость автомобиля в несколько раз и вывести новое состояние.
for (int i = 0; i <= 10; i++)
{
myCar.SpeedUp(5);
myCar.PrintState();
}
Console.ReadLine();
Запустив программу, вы увидите, что переменная
Car
(myCar
) поддерживает свое текущее состояние на протяжении жизни приложения:
***** Fun with Class Types *****
Henry is going 15 MPH.
Henry is going 20 MPH.
Henry is going 25 MPH.
Henry is going 30 MPH.
Henry is going 35 MPH.
Henry is going 40 MPH.
Henry is going 45 MPH.
Henry is going 50 MPH.
Henry is going 55 MPH.
Henry is going 60 MPH.
Henry is going 65 MPH.
Как было показано в предыдущем примере кода, объекты должны размещаться в памяти с применением ключевого слова
new
. Если вы не укажете ключевое слово new
и попытаетесь использовать переменную класса в последующем операторе кода, то получите ошибку на этапе компиляции. Например, приведенные далее операторы верхнего уровня не скомпилируются:
Console.WriteLine("***** Fun with Class Types *****\n");
// Ошибка на этапе компиляции! Забыли использовать new для создания объекта!
Car myCar;
myCar.petName = "Fred";
Чтобы корректно создать объект с применением ключевого слова
new
, можно определить и разместить в памяти объект Car
в одной строке кода:
Console.WriteLine("***** Fun with Class Types *****\n");
Car myCar = new Car();
myCar.petName = "Fred";
В качестве альтернативы определение и размещение в памяти экземпляра класса может осуществляться в отдельных строках кода:
Console.WriteLine("***** Fun with Class Types *****\n");
Car myCar;
myCar = new Car();
myCar.petName = "Fred";
Здесь первый оператор кода просто объявляет ссылку на определяемый объект типа
Car
. Ссылка будет указывать на действительный объект в памяти только после ее явного присваивания.
В любом случае к настоящему моменту мы имеем простейший класс, в котором определено несколько элементов данных и ряд базовых операций. Чтобы расширить функциональность текущего класса
Car
, необходимо разобраться с ролью конструкторов.
Учитывая наличие у объекта состояния (представленного значениями его переменных-членов), обычно желательно присвоить подходящие значения полям объекта перед тем, как работать с ним. В настоящее время класс
Car
требует присваивания значений полям petName
и currSpeed
по отдельности. Для текущего примера такое действие не слишком проблематично, поскольку открытых элементов данных всего два. Тем не менее, зачастую класс содержит несколько десятков полей, с которыми надо что-то делать. Ясно, что было бы нежелательно писать 20 операторов инициализации для всех 20 элементов данных.
К счастью, язык C# поддерживает использование конструкторов, которые позволяют устанавливать состояние объекта в момент его создания. Конструктор — это специальный метод класса, который неявно вызывается при создании объекта с применением ключевого слова new. Однако в отличие от "нормального" метода конструктор никогда не имеет возвращаемого значения (даже
void
) и всегда именуется идентично имени класса, объекты которого он конструирует.
Каждый класс C# снабжается "бесплатным" стандартным конструктором, который в случае необходимости может быть переопределен. По определению стандартный конструктор никогда не принимает аргументов. После размещения нового объекта в памяти стандартный конструктор гарантирует установку всех полей данных в соответствующие стандартные значения (стандартные значения для типов данных C# были описаны в главе 3).
Если вас не устраивают такие стандартные присваивания, тогда можете переопределить стандартный конструктор в соответствии со своими нуждами. В целях иллюстрации модифицируем класс C# следующим образом:
class Car
{
// 'Состояние' объекта Car.
public string petName;
public int currSpeed;
// Специальный стандартный конструктор.
public Car()
{
petName = "Chuck";
currSpeed = 10;
...
}
В данном случае мы заставляем объекты
Car
начинать свое существование под именем Chuck
и со скоростью 10 миль в час. Создать объект Car
со стандартными значениями можно так:
Console.WriteLine("***** Fun with Class Types *****\n");
// Вызов стандартного конструктора.
Car chuck = new Car();
// Выводит строку "Chuck is going 10 MPH."
chuck.PrintState();
...
Обычно помимо стандартного конструктора в классах определяются дополнительные конструкторы. Тем самым пользователю объекта предоставляется простой и согласованный способ инициализации состояния объекта прямо во время его создания. Взгляните на следующее изменение класса
Car
, который теперь поддерживает в совокупности три конструктора:
class Car
{
// 'Состояние' объекта Car.
public string petName;
public int currSpeed;
// Специальный стандартный конструктор.
public Car()
{
petName = "Chuck";
currSpeed = 10;
}
// Здесь currSpeed получает стандартное значение для типа int (0).
public Car(string pn)
{
petName = pn;
}
// Позволяет вызывающему коду установить полное состояние объекта Car.
public Car(string pn, int cs)
{
petName = pn;
currSpeed = cs;
}
...
}
Имейте в виду, что один конструктор отличается от другого (с точки зрения компилятора С#) числом и/или типами аргументов. Вспомните из главы 4, что определение метода с тем же самым именем, но разным количеством или типами аргументов, называется перегрузкой метода. Таким образом, конструктор класса
Car
перегружен, чтобы предложить несколько способов создания объекта во время объявления. В любом случае теперь есть возможность создавать объекты Car
, используя любой из его открытых конструкторов. Вот пример:
Console.WriteLine("***** Fun with Class Types *****\n");
// Создать объект Car по имени Chuck со скоростью 10 миль в час.
Car chuck = new Car();
chuck.PrintState();
// Создать объект Car по имени Mary со скоростью 0 миль в час.
Car mary = new Car("Mary");
mary.PrintState();
// Создать объект Car по имени Daisy со скоростью 75 миль в час.
Car daisy = new Car("Daisy", 75);
daisy.PrintState();
...
В C# 7 появились дополнительные случаи употребления для стиля членов, сжатых до выражений. Теперь такой синтаксис применим к конструкторам, финализаторам, а также к средствам доступа
get/set
для свойств и индексаторов. С учетом сказанного предыдущий конструктор можно переписать следующим образом:
// Здесь currSpeed получит стандартное
// значение для типа int (0).
public Car(string pn) => petName = pn;
Второй специальный конструктор не может быть преобразован в выражение, т.к. члены, сжатые до выражений, должны быть однострочными методами.
Начиная с версии C# 7.3, в конструкторах (а также в рассматриваемых позже инициализаторах полей и свойств) могут использоваться параметры
out
. В качестве простого примера добавьте в класс Car
следующий конструктор:
public Car(string pn, int cs, out bool inDanger)
{
petName = pn;
currSpeed = cs;
if (cs > 100)
{
inDanger = true;
}
else
{
inDanger = false;
}
}
Как обычно, должны соблюдаться все правила, касающиеся параметров
out
. В приведенном примере параметру inDanger
потребуется присвоить значение до завершения конструктора.
Как вы только что узнали, все классы снабжаются стандартным конструктором. Добавьте в свой проект новый файл по имени
Motorcycle.cs
с показанным ниже определением класса Motorcycle
:
using System;
namespace SimpleClassExample
{
class Motorcycle
{
public void PopAWheely()
{
Console.WriteLine("Yeeeeeee Haaaaaeewww!");
}
}
}
Теперь появилась возможность создания экземпляров
Motorcycle
с помощью стандартного конструктора:
Console.WriteLine("***** Fun with Class Types *****\n");
Motorcycle mc = new Motorcycle();
mc.PopAWheely();
...
Тем не менее, как только определен специальный конструктор с любым числом параметров, стандартный конструктор молча удаляется из класса и перестает быть доступным. Воспринимайте это так: если вы не определили специальный конструктор, тогда компилятор C# снабжает класс стандартным конструктором, давая возможность пользователю размещать в памяти экземпляр вашего класса с набором полей данных, которые установлены в корректные стандартные значения. Однако когда вы определяете уникальный конструктор, то компилятор предполагает, что вы решили взять власть в свои руки.
Следовательно, если вы хотите позволить пользователю создавать экземпляр вашего типа с помощью стандартного конструктора, а также специального конструктора, то должны явно переопределить стандартный конструктор. Важно понимать, что в подавляющем большинстве случаев реализация стандартного конструктора класса намеренно оставляется пустой, т.к. требуется только создание объекта со стандартными значениями. Обновите класс
Motorcycle
:
class Motorcycle
{
public int driverIntensity;
public void PopAWheely()
{
for (int i = 0; i <= driverIntensity; i++)
{
Console.WriteLine("Yeeeeeee Haaaaaeewww!");
}
}
// Вернуть стандартный конструктор, который будет.
// устанавливать все члены данных в стандартные значения
public Motorcycle() {}
// Специальный конструктор.
public Motorcycle(int intensity)
{
driverIntensity = intensity;
}
}
На заметку! Теперь, когда вы лучше понимаете роль конструкторов класса, полезно узнать об одном удобном сокращении. В Visual Studio и Visual Studio Code предлагается фрагмент кода
ctor
. Если вы наберете ctor
и нажмете клавишу <ТаЬ>, тогда IDE-среда автоматически определит специальный стандартный конструктор. Затем можно добавить нужные параметры и логику реализации. Испытайте такой прием.
В языке C# имеется ключевое слово
this
, которое обеспечивает доступ к текущему экземпляру класса. Один из возможных сценариев использования this
предусматривает устранение неоднозначности с областью видимости, которая может возникнуть, когда входной параметр имеет такое же имя, как и поле данных класса. Разумеется, вы могли бы просто придерживаться соглашения об именовании, которое не приводит к такой неоднозначности; тем не менее, чтобы проиллюстрировать такой сценарий, добавьте в класс Motorcycle
новое поле типа string
(под названием name
), предназначенное для представления имени водителя. Затем добавьте метод SetDriverName()
со следующей реализацией:
class Motorcycle
{
public int driverIntensity;
// Новые члены для представления имени водителя.
public string name;
public void SetDriverName(string name) => name = name;
...
}
Хотя приведенный код нормально скомпилируется, компилятор C# выдаст сообщение с предупреждением о том, что переменная присваивается сама себе! В целях иллюстрации добавьте в свой код вызов метода
SetDriverName()
и обеспечьте вывод значения поля name. Вы можете быть удивлены, обнаружив, что значением поля name является пустая строка!
// Создать объект Motorcycle с мотоциклистом по имени Tiny?
Motorcycle c = new Motorcycle(5);
c.SetDriverName("Tiny");
c.PopAWheely();
Console.WriteLine("Rider name is {0}", c.name); // Выводит пустое значение name!
Проблема в том, что реализация метода
SetDriverName()
присваивает входному параметру значение его самого, т.к. компилятор предполагает, что name
ссылается на переменную, находящуюся в области видимости метода, а не на поле name
из области видимости класса. Для информирования компилятора о том, что необходимо установить поле данных name
текущего объекта в значение входного параметра name, просто используйте ключевое слово this
, устранив такую неоднозначность:
public void SetDriverName(string name) => this.name = name;
Если неоднозначность отсутствует, тогда применять ключевое слово
this
для доступа класса к собственным полям данных или членам вовсе не обязательно. Например, если вы переименуете член данных типа string
с name
на driverName
(что также повлечет за собой модификацию операторов верхнего уровня), то потребность в использовании this
отпадет, поскольку неоднозначности с областью видимости больше нет:
class Motorcycle
{
public int driverIntensity;
public string driverName;
public void SetDriverName(string name)
{
// These two statements are functionally the same.
driverName = name;
this.driverName = name;
}
...
}
Несмотря на то что применение ключевого слова
this
в неоднозначных ситуациях дает не особенно большой выигрыш, вы можете счесть его удобным при реализации членов класса, т.к. IDE-среды, подобные Visual Studio и Visual Studio Code, будут активизировать средство IntelliSense, когда присутствует this
. Это может оказаться полезным, если вы забыли имя члена класса и хотите быстро вспомнить его определение.
На заметку! Общепринятое соглашение об именовании предусматривает снабжение имен закрытых (или внутренних) переменных уровня класса префиксом в виде символа подчеркивания (скажем,
_driverName
), чтобы средство IntelliSense отображало все ваши переменные в верхней части списка. В нашем простом примере все поля являются открытыми, поэтому такое соглашение об именовании не применяется. В остальном материале книги закрытые и внутренние переменные будут именоваться с ведущим символом подчеркивания.
Еще один сценарий применения ключевого слова
this
касается проектирования класса с использованием приема, который называется построением цепочки конструкторов. Такой паттерн проектирования полезен при наличии класса, определяющего множество конструкторов. Учитывая тот факт, что конструкторы нередко проверяют входные аргументы на предмет соблюдения разнообразных бизнес-правил, довольно часто внутри набора конструкторов обнаруживается избыточная логика проверки достоверности. Рассмотрим следующее модифицированное определение класса Motorcycle
:
class Motorcycle
{
public int driverIntensity;
public string driverName;
public Motorcycle() { }
// Избыточная логика конструктора!
public Motorcycle(int intensity)
{
if (intensity > 10)
{
intensity = 10;
}
driverIntensity = intensity;
}
public Motorcycle(int intensity, string name)
{
if (intensity > 10)
{
intensity = 10;
}
driverIntensity = intensity;
driverName = name;
}
...
}
Здесь (возможно в попытке обеспечить безопасность мотоциклиста) внутри каждого конструктора производится проверка того, что уровень мощности не превышает значения 10. Наряду с тем, что это правильно, в двух конструкторах присутствует избыточный код. Подход далек от идеала, поскольку в случае изменения правил (например, если уровень мощности не должен превышать значение 5 вместо 10) код придется модифицировать в нескольких местах.
Один из способов улучшить создавшуюся ситуацию предусматривает определение в классе
Motorcycle
метода, который будет выполнять проверку входных аргументов. Если вы решите поступить так, тогда каждый конструктор сможет вызывать такой метод перед присваиванием значений полям. Хотя описанный подход позволяет изолировать код, который придется обновлять при изменении бизнес-правил, теперь появилась другая избыточность:
class Motorcycle
{
public int driverIntensity;
public string driverName;
// Конструкторы.
public Motorcycle() { }
public Motorcycle(int intensity)
{
SetIntensity(intensity);
}
public Motorcycle(int intensity, string name)
{
SetIntensity(intensity);
driverName = name;
}
public void SetIntensity(int intensity)
{
if (intensity > 10)
{
intensity = 10;
}
driverIntensity = intensity;
}
...
}
Более совершенный подход предполагает назначение конструктора, который принимает наибольшее количество аргументов, в качестве "главного конструктора" и выполнение требуемой логики проверки достоверности внутри его реализации. Остальные конструкторы могут применять ключевое слово
this
для передачи входных аргументов главному конструктору и при необходимости предоставлять любые дополнительные параметры. В таком случае вам придется беспокоиться только о поддержке единственного конструктора для всего класса, в то время как оставшиеся конструкторы будут в основном пустыми.
Ниже представлена финальная реализация класса
Motorcycle
(с одним дополнительным конструктором в целях иллюстрации). При связывании конструкторов в цепочку обратите внимание, что ключевое слово this
располагается за пределами самого конструктора и отделяется от его объявления двоеточием:
class Motorcycle
{
public int driverIntensity;
public string driverName;
// Связывание конструкторов в цепочку.
public Motorcycle() {}
public Motorcycle(int intensity)
: this(intensity, "") {}
public Motorcycle(string name)
: this(0, name) {}
// Это 'главный' конструктор, выполняющий всю реальную работу.
public Motorcycle(int intensity, string name)
{
if (intensity > 10)
{
intensity = 10;
}
driverIntensity = intensity;
driverName = name;
}
...
}
Имейте в виду, что использовать ключевое слово
this
для связывания вызовов конструкторов в цепочку вовсе не обязательно. Однако такой подход позволяет получить лучше сопровождаемое и более краткое определение класса. Применяя данный прием, также можно упростить решение задач программирования, потому что реальная работа делегируется единственному конструктору (обычно принимающему большую часть параметров), тогда как остальные просто "перекладывают на него ответственность".
На заметку! Вспомните из главы 4, что в языке C# поддерживаются необязательные параметры. Если вы будете использовать в конструкторах своих классов необязательные параметры, то сможете добиться тех же преимуществ, что и при связывании конструкторов в цепочку, но с меньшим объемом кода. Вскоре вы увидите, как это делается.
Напоследок отметим, что как только конструктор передал аргументы выделенному главному конструктору (и главный конструктор обработал данные), первоначально вызванный конструктор продолжит выполнение всех оставшихся операторов кода. В целях прояснения модифицируйте конструкторы класса
Motorcycle
, добавив в них вызов метода Console.WriteLine()
:
class Motorcycle
{
public int driverIntensity;
public string driverName;
// Связывание конструкторов в цепочку.
public Motorcycle()
{
Console.WriteLine("In default ctor");
// Внутри стандартного конструктора
}
public Motorcycle(int intensity)
: this(intensity, "")
{
Console.WriteLine("In ctor taking an int");
// Внутри конструктора, принимающего int
}
public Motorcycle(string name)
: this(0, name)
{
Console.WriteLine("In ctor taking a string");
// Внутри конструктора, принимающего string
}
// Это 'главный' конструктор, выполняющий всю реальную работу.
public Motorcycle(int intensity, string name)
{
Console.WriteLine("In master ctor ");
// Внутри главного конструктора
if (intensity > 10)
{
intensity = 10;
}
driverIntensity = intensity;
driverName = name;
}
...
}
Теперь измените операторы верхнего уровня, чтобы они работали с объектом
Motorcycle
:
Console.WriteLine("***** Fun with class Types *****\n");
// Создать объект Motorcycle.
Motorcycle c = new Motorcycle(5);
c.SetDriverName("Tiny");
c.PopAWheely();
Console.WriteLine("Rider name is {0}", c.driverName);
// вывод имени гонщика
Console.ReadLine();
Вот вывод, полученный в результате выполнения показанного выше кода:
***** Fun with Motorcycles *****
In master ctor
In ctor taking an int
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Rider name is Tiny
Ниже описан поток логики конструкторов.
• Первым делом создается объект путем вызова конструктора, принимающего один аргумент типа
int
.
• Этот конструктор передает полученные данные главному конструктору и предоставляет любые дополнительные начальные аргументы, не указанные вызывающим кодом.
• Главный конструктор присваивает входные данные полям данных объекта.
• Управление возвращается первоначально вызванному конструктору, который выполняет оставшиеся операторы кода.
В построении цепочек конструкторов примечательно то, что данный шаблон программирования будет работать с любой версией языка C# и платформой .NET Core. Тем не менее, если целевой платформой является .NET 4.0 или последующая версия, то решение задач можно дополнительно упростить, применяя необязательные аргументы в качестве альтернативы построению традиционных цепочек конструкторов.
В главе 4 вы изучили необязательные и именованные аргументы. Вспомните, что необязательные аргументы позволяют определять стандартные значения для входных аргументов. Если вызывающий код устраивают стандартные значения, то указывать уникальные значения не обязательно, но это нужно делать, чтобы снабдить объект специальными данными. Рассмотрим следующую версию класса
Motorcycle
, которая теперь предлагает несколько возможностей конструирования объектов, используя единственное определение конструктора:
class Motorcycle
{
// Единственный конструктор, использующий необязательные аргументы.
public Motorcycle(int intensity = 0, string name = "")
{
if (intensity > 10)
{
intensity = 10;
}
driverIntensity = intensity;
driverName = name;
}
...
}
С помощью такого единственного конструктора можно создавать объект
Motorcycle
, указывая ноль, один или два аргумента. Вспомните, что синтаксис именованных аргументов по существу позволяет пропускать подходящие стандартные установки (см. главу 4).
static void MakeSomeBikes()
{
// driverName = "", driverIntensity = 0
Motorcycle m1 = new Motorcycle();
Console.WriteLine("Name= {0}, Intensity= {1}",
m1.driverName, m1.driverIntensity);
// driverName = "Tiny", driverIntensity = 0
Motorcycle m2 = new Motorcycle(name:"Tiny");
Console.WriteLine("Name= {0}, Intensity= {1}",
m2.driverName, m2.driverIntensity);
// driverName = "", driverIntensity = 7
Motorcycle m3 = new Motorcycle(7);
Console.WriteLine("Name= {0}, Intensity= {1}",
m3.driverName, m3.driverIntensity);
}
В любом случае к настоящему моменту вы способны определить класс с полями данных (т.е. переменными-членами) и разнообразными операциями, такими как методы и конструкторы. А теперь формализуем роль ключевого слова
static
.
В классе C# можно определять любое количество статических членов, объявляемых с применением ключевого слова
static
. В таком случае интересующий член должен вызываться прямо на уровне класса, а не через переменную со ссылкой на объект. Чтобы проиллюстрировать разницу, обратимся к нашему старому знакомому классу System.Console
. Как вы уже видели, метод WriteLine()
не вызывается на уровне объекта:
// Ошибка на этапе компиляции! WriteLine() - не метод уровня объекта!
Console c = new Console();
c.WriteLine("I can't be printed...");
Взамен статический член
WriteLine()
предваряется именем класса:
// Правильно! WriteLine() - статический метод.
Console.WriteLine("Much better! Thanks...");
Выражаясь просто, статические члены — это элементы, которые проектировщик класса посчитал настолько общими, что перед обращением к ним даже нет нужды создавать экземпляр класса. Наряду с тем, что определять статические члены можно в любом классе, чаще всего они обнаруживаются внутри обслуживающих классов. По определению обслуживающий класс представляет собой такой класс, который не поддерживает какое-либо состояние на уровне объектов и не предполагает создание своих экземпляров с помощью ключевого слова new. Взамен обслуживающий класс открывает доступ ко всей функциональности посредством членов уровня класса (также известных под названием статических).
Например, если бы вы воспользовались браузером объектов Visual Studio (выбрав пункт меню View►Object Browser (Вид►Браузер объектов)) для просмотра пространства имен
System
, то увидели бы, что все члены классов Console
, Math
, Environment
и GC
(среди прочих) открывают доступ к своей функциональности через статические члены. Они являются лишь несколькими обслуживающими классами, которые можно найти в библиотеках базовых классов .NET Core.
И снова следует отметить, что статические члены находятся не только в обслуживающих классах: они могут быть частью в принципе любого определения класса. Просто запомните, что статические члены продвигают отдельный элемент на уровень класса вместо уровня объектов. Как будет показано в нескольких последующих разделах, ключевое слово
static
может применяться к перечисленным ниже конструкциям:
• данные класса;
• методы класса;
• свойства класса;
• конструктор;
• полное определение класса;
• в сочетании с ключевым словом
using
.
Давайте рассмотрим все варианты, начав с концепции статических данных.
На заметку! Роль статических свойств будет объясняться позже в главе во время исследования самих свойств.
При проектировании класса в большинстве случаев данные определяются на уровне экземпляра — другими словами, как нестатические данные. Когда определяются данные уровня экземпляра, то известно, что каждый создаваемый новый объект поддерживает собственную независимую копию этих данных. По контрасту при определении статических данных класса выделенная под них память разделяется всеми объектами этой категории.
Чтобы увидеть разницу, создайте новый проект консольного приложения под названием
StaticDataAndMembers
. Добавьте в проект файл по имени SavingsAccount.cs
и создайте в нем класс SavingsAccount
. Начните с определения переменной уровня экземпляра (для моделирования текущего баланса) и специального конструктора для установки начального баланса:
using System;
namespace StaticDataAndMembers
{
// Простой класс депозитного счета.
class SavingsAccount
{
// Данные уровня экземпляра.
public double currBalance;
public SavingsAccount(double balance)
{
currBalance = balance;
}
}
}
При создании объектов
SavingsAccount
память под поле currBalance
выделяется для каждого объекта. Таким образом, можно было бы создать пять разных объектов SavingsAccount
, каждый с собственным уникальным балансом. Более того, в случае изменения баланса в одном объекте счета другие объекты не затрагиваются.
С другой стороны, память под статические данные распределяется один раз и используется всеми объектами того же самого класса. Добавьте в класс
SavingsAccount
статическую переменную по имени currInterestRate
, которая устанавливается в стандартное значение 0.04
:
// Простой класс депозитного счета.
class SavingsAccount
{
// Статический элемент данных.
public static double currInterestRate = 0.04;
// Данные уровня экземпляра.
public double currBalance;
public SavingsAccount(double balance)
{
currBalance = balance;
}
}
Создайте три экземпляра класса
SavingsAccount
, как показано ниже:
using System;
using StaticDataAndMembers;
Console.WriteLine("***** Fun with Static Data *****\n");
SavingsAccount s1 = new SavingsAccount(50);
SavingsAccount s2 = new SavingsAccount(100);
SavingsAccount s3 = new SavingsAccount(10000.75);
Console.ReadLine();
Размещение данных в памяти будет выглядеть примерно так, как иллюстрируется на рис. 5.1.
Здесь предполагается, что все депозитные счета должны иметь одну и ту же процентную ставку. Поскольку статические данные разделяются всеми объектами той же самой категории, если вы измените процентную ставку каким-либо образом, тогда все объекты будут "видеть" новое значение при следующем доступе к статическим данным, т.к. все они по существу просматривают одну и ту же ячейку памяти. Чтобы понять, как изменять (или получать) статические данные, понадобится рассмотреть роль статических методов.
Модифицируйте класс
SavingsAccount
с целью определения в нем двух статических методов. Первый статический метод (GetInterestRate()
) будет возвращать текущую процентную ставку, а второй (SetInterestRate()
) позволит изменять эту процентную ставку:
// Простой класс депозитного счета.
class SavingsAccount
{
// Данные уровня экземпляра.
public double currBalance;
// Статический элемент данных.
public static double currInterestRate = 0.04;
public SavingsAccount(double balance)
{
currBalance = balance;
}
// Статические члены для установки/получения процентной ставки.
public static void SetInterestRate(double newRate)
=> currInterestRate = newRate;
public static double GetInterestRate()
=> currInterestRate;
}
Рассмотрим показанный ниже сценарий использования класса:
using System;
using StaticDataAndMembers;
Console.WriteLine("***** Fun with Static Data *****\n");
SavingsAccount s1 = new SavingsAccount(50);
SavingsAccount s2 = new SavingsAccount(100);
// Вывести текущую процентную ставку.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());
// Создать новый объект; это не 'сбросит' процентную ставку.
SavingsAccount s3 = new SavingsAccount(10000.75);
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());
Console.ReadLine();
Вывод предыдущего кода выглядит так:
***** Fun with Static Data *****
Interest Rate is: 0.04
Interest Rate is: 0.04
Как видите, при создании новых экземпляров класса
SavingsAccount
значение статических данных не сбрасывается, поскольку среда CoreCLR выделяет для них место в памяти только один раз. Затем все объекты типа SavingsAccount
имеют дело с одним и тем же значением в статическом поле currInterestRate
.
Когда проектируется любой класс С#, одна из задач связана с выяснением того, какие порции данных должны быть определены как статические члены, а какие — нет. Хотя строгих правил не существует, запомните, что поле статических данных разделяется между всеми объектами конкретного класса. Поэтому, если необходимо, чтобы часть данных совместно использовалась всеми объектами, то статические члены будут самым подходящим вариантом.
Посмотрим, что произойдет, если поле
currInterestRate
не определено с ключевым словом static
. Это означает, что каждый объект SavingAccount
будет иметь собственную копию поля currInterestRate
. Предположим, что вы создали сто объектов SavingAccount
и нуждаетесь в изменении размера процентной ставки. Такое действие потребовало бы вызова метода SetInterestRate()
сто раз! Ясно, что подобный способ моделирования "разделяемых данных" трудно считать удобным. Статические данные безупречны в ситуации, когда есть значение, которое должно быть общим для всех объектов заданной категории.
На заметку! Ссылка на нестатические члены внутри реализации статического члена приводит к ошибке на этапе компиляции. В качестве связанного замечания: ошибкой также будет применение ключевого слова
this
к статическому члену, потому что this
подразумевает объект!
Типичный конструктор используется для установки значений данных уровня экземпляра во время его создания. Однако что произойдет, если вы попытаетесь присвоить значение статическому элементу данных в типичном конструкторе? Вы можете быть удивлены, обнаружив, что значение сбрасывается каждый раз, когда создается новый объект.
В целях иллюстрации модифицируйте код конструктора класса
SavingsAccount
, как показано ниже (также обратите внимание, что поле currInterestRate
больше не устанавливается при объявлении):
class SavingsAccount
{
public double currBalance;
public static double currInterestRate;
// Обратите внимание, что наш конструктор устанавливает
// значение статического поля currInterestRate.
public SavingsAccount(double balance)
{
currInterestRate = 0.04; // Это статические данные!
currBalance = balance;
}
...
}
Теперь добавьте к операторам верхнего уровня следующий код:
// Создать объект счета.
SavingsAccount s1 = new SavingsAccount(50);
// Вывести текущую процентную ставку.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());
// Попытаться изменить процентную ставку через свойство.
SavingsAccount.SetInterestRate(0.08);
// Создать второй объект счета.
SavingsAccount s2 = new SavingsAccount(100);
// Должно быть выведено 0.08, не так ли?
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());
Console.ReadLine();
При выполнении этого кода вы увидите, что переменная
currInterestRate
сбрасывается каждый раз, когда создается новый объект SavingsAccount
, и она всегда установлена в 0.04
. Очевидно, что установка значений статических данных в нормальном конструкторе уровня экземпляра сводит на нет все их предназначение. Когда бы ни создавался новый объект, данные уровня класса сбрасываются! Один из подходов к установке статического поля предполагает применение синтаксиса инициализации членов, как делалось изначально:
class SavingsAccount
{
public double currBalance;
// Статические данные.
public static double currInterestRate = 0.04;
...
}
Такой подход обеспечит установку статического поля только один раз независимо от того, сколько объектов создается. Но что, если значение статических данных необходимо получать во время выполнения? Например, в типичном банковском приложении значение переменной, представляющей процентную ставку, будет читаться из базы данных или внешнего файла. Решение задач подобного рода обычно требует области действия метода, такого как конструктор, для выполнения соответствующих операторов кода.
По этой причине язык C# позволяет определять статический конструктор, который дает возможность безопасно устанавливать значения статических данных. Взгляните на следующее изменение в коде класса:
class SavingsAccount
{
public double currBalance;
public static double currInterestRate;
public SavingsAccount(double balance)
{
currBalance = balance;
}
// Статический конструктор!
static SavingsAccount()
{
Console.WriteLine("In static ctor!");
currInterestRate = 0.04;
}
...
}
Выражаясь просто, статический конструктор представляет собой специальный конструктор, который является идеальным местом для инициализации значений статических данных, если их значения не известны на этапе компиляции (например, когда значения нужно прочитать из внешнего файла или базы данных, сгенерировать случайные числа либо получить значения еще каким-нибудь способом). Если вы снова запустите предыдущий код, то увидите ожидаемый вывод. Обратите внимание, что сообщение
"In static ctor!"
выводится только один раз, т.к. среда CoreCLR вызывает все статические конструкторы перед первым использованием (и никогда не вызывает их заново для данного экземпляра приложения):
***** Fun with Static Data *****
In static ctor!
Interest Rate is: 0.04
Interest Rate is: 0.08
Ниже отмечено несколько интересных моментов, касающихся статических конструкторов.
• В отдельно взятом классе может быть определен только один статический конструктор. Другими словами, перегружать статический конструктор нельзя.
• Статический конструктор не имеет модификатора доступа и не может принимать параметры.
• Статический конструктор выполняется только один раз вне зависимости от количества создаваемых объектов заданного класса.
• Исполняющая система вызывает статический конструктор, когда создает экземпляр класса или перед доступом к первому статическому члену из вызывающего кода.
• Статический конструктор выполняется перед любым конструктором уровня экземпляра.
С учетом такой модификации при создании новых объектов
SavingsAccount
значения статических данных предохраняются, поскольку статический член устанавливается только один раз внутри статического конструктора независимо от количества созданных объектов.
Ключевое слово
static
допускается также применять прямо на уровне класса. Когда класс определен как статический, его экземпляры нельзя создавать с использованием ключевого слова new
, и он может содержать только члены или поля данных, помеченные ключевым словом static
. В случае нарушения этого правила возникают ошибки на этапе компиляции.
На заметку! Вспомните, что класс (или структура), который открывает доступ только к статической функциональности, часто называется обслуживающим классом. При проектировании обслуживающего класса рекомендуется применять ключевое слово static к самому определению класса.
На первый взгляд такое средство может показаться довольно странным, учитывая невозможность создания экземпляров класса. Тем не менее, в первую очередь класс, который содержит только статические члены и/или константные данные, не нуждается в выделении для него памяти. В целях иллюстрации определите новый класс по имени
TimeUtilClass
:
using System;
namespace StaticDataAndMembers
{
// Статические классы могут содержать только статические члены!
static class TimeUtilClass
{
public static void PrintTime()
=> Console.WriteLine(DateTime.Now.ToShortTimeString());
public static void PrintDate()
=> Console.WriteLine(DateTime.Today.ToShortDateString());
}
}
Так как класс
TimeUtilClass
определен с ключевым словом static
, создавать его экземпляры с помощью ключевого слова new
нельзя. Взамен вся функциональность доступна на уровне класса. Чтобы протестировать данный класс, добавьте к операторам верхнего уровня следующий код:
// Это работает нормально.
TimeUtilClass.PrintDate();
TimeUtilClass.PrintTime();
// Ошибка на этапе компиляции!
// Создавать экземпляры статического класса невозможно!
TimeUtilClass u = new TimeUtilClass ();
Console.ReadLine();
В версии C# 6 появилась поддержка импортирования статических членов с помощью ключевого слова
using
. В качестве примера предположим, что в файле C# определен обслуживающий класс. Поскольку в нем делаются вызовы метода WriteLine()
класса Console
, а также обращения к свойствам Now
и Today
класса DateTime
, должен быть предусмотрен оператор using
для пространства имен System
. Из-за того, что все члены упомянутых классов являются статическими, в файле кода можно указать следующие директивы using static
:
// Импортировать статические члены классов Console и DateTime.
using static System.Console;
using static System.DateTime;
После такого "статического импортирования" в файле кода появляется возможность напрямую применять статические методы классов
Console
и DateTime
, не снабжая их префиксом в виде имени класса, в котором они определены. Например, модифицируем наш обслуживающий класс TimeUtilClass
, как показано ниже:
static class TimeUtilClass
{
public static void PrintTime()
=> WriteLine(Now.ToShortTimeString());
public static void PrintDate()
=> WriteLine(Today.ToShortDateString());
}
В более реалистичном примере упрощения кода за счет импортирования статических членов мог бы участвовать класс С#, интенсивно использующий класс
System.Math
(или какой-то другой обслуживающий класс). Поскольку этот класс содержит только статические члены, отчасти было бы проще указать для него оператор using static
и затем напрямую обращаться членам класса Math
в своем файле кода.
Однако имейте в виду, что злоупотребление операторами статического импортирования может привести в результате к путанице. Во-первых, как быть, если метод
WriteLine()
определен сразу в нескольких классах? Будет сбит с толку как компилятор, так и другие программисты, читающие ваш код. Во-вторых, если разработчик не особенно хорошо знаком с библиотеками кода .NET Core, то он может не знать о том, что WriteLine()
является членом класса Console
. До тех пор, пока разработчик не заметит набор операторов статического импортирования в начале файла кода С#, он не может быть полностью уверен в том, где данный метод фактически определен. По указанным причинам применение операторов using static
в книге ограничено.
К настоящему моменту вы должны уметь определять простые типы классов, содержащие конструкторы, поля и разнообразные статические (и нестатические) члены. Обладая такими базовыми знаниями о конструкции классов, можно приступать к ознакомлению с тремя основными принципами объектно-ориентированного программирования (ООП).
Все объектно-ориентированные языки (С#, Java, C++, Visual Basic и т.д.) должны поддерживать три основных принципа ООП.
• Инкапсуляция. Каким образом язык скрывает детали внутренней реализации объектов и предохраняет целостность данных?
• Наследование. Каким образом язык стимулирует многократное использование кода?
• Полиморфизм. Каким образом язык позволяет трактовать связанные объекты в сходной манере?
Прежде чем погрузиться в синтаксические детали каждого принципа, важно понять их базовые роли. Ниже предлагается обзор всех принципов, а в оставшейся части этой и в следующей главе приведены подробные сведения, связанные с ними.
Первый основной принцип ООП называется инкапсуляцией. Такая характерная черта описывает способность языка скрывать излишние детали реализации от пользователя объекта. Например, предположим, что вы имеете дело с классом по имени
DatabaseReader
, в котором определены два главных метода: Open()
и Close()
.
// Пусть этот класс инкапсулирует детали открытия и закрытия базы данных.
DatabaseReader dbReader = new DatabaseReader();
dbReader.Open(@"C:\AutoLot.mdf");
// Сделать что-то с файлом данных и закрыть файл.
dbReader.Close();
Вымышленный класс
DatabaseReader
инкапсулирует внутренние детали нахождения, загрузки, манипулирования и закрытия файла данных. Программистам нравится инкапсуляция, т.к. этот основной принцип ООП упрощает задачи кодирования. Отсутствует необходимость беспокоиться о многочисленных строках кода, которые работают "за кулисами", чтобы обеспечить функционирование класса DatabaseReader
. Все, что понадобится — создать экземпляр и отправить ему подходящие сообщения (например, открыть файл по имени AutoLot.mdf
, расположенный на диске С:).
С понятием инкапсуляции программной логики тесно связана идея защиты данных. В идеале данные состояния объекта должны быть определены с применением одного из ключевых слов
private
, internal
или protected
. В итоге внешний мир должен вежливо попросить об изменении либо извлечении лежащего в основе значения, что крайне важно, т.к. открыто объявленные элементы данных легко могут стать поврежденными (конечно, лучше случайно, чем намеренно). Вскоре будет дано формальное определение такого аспекта инкапсуляции.
Следующий принцип ООП — наследование — отражает возможность языка разрешать построение определений новых классов на основе определений существующих классов. По сути, наследование позволяет расширять поведение базового (или родительского) класса за счет наследования его основной функциональности производным подклассом (также называемым дочерним классом). На рис. 5.2 показан простой пример.
Диаграмма на рис. 5.2 читается так: "шестиугольник (
Hexagon
) является фигурой (Shape
), которая является объектом (Object
)". При наличии классов, связанных такой формой наследования, между типами устанавливается отношение "является" ("is-a"). Отношение "является" называется наследованием.
Здесь можно предположить, что класс
Shape
определяет некоторое количество членов, являющихся общими для всех наследников (скажем, значение для представления цвета фигуры, а также значения для высоты и ширины). Учитывая, что класс Hexagon
расширяет Shape
, он наследует основную функциональность, определяемую классами Shape
и Object
, и вдобавок сам определяет дополнительные детали, связанные с шестиугольником (какими бы они ни были).
На заметку! В рамках платформ .NET/.NET Core класс
System.Object
всегда находится на вершине любой иерархии классов, являясь первоначальным родительским классом, и определяет общую функциональность для всех типов (как подробно объясняется в главе 6).
В мире ООП существует еще одна форма повторного использования кода: модель включения/делегации, также известная как отношение "имеет" ("has-a") или агрегация. Такая форма повторного использования не применяется для установки отношений "родительский-дочерний". На самом деле отношение "имеет" позволяет одному классу определять переменную-член другого класса и опосредованно (когда требуется) открывать доступ к его функциональности пользователю объекта.
Например, предположим, что снова моделируется автомобиль. Может возникнуть необходимость выразить идею, что автомобиль "имеет" радиоприемник. Было бы нелогично пытаться наследовать класс
Car
(автомобиль) от класса Radio
(радиоприемник) или наоборот (ведь Car
не "является" Radio
). Взамен есть два независимых класса, работающих совместно, где класс Car
создает и открывает доступ к функциональности класса Radio
:
class Radio
{
public void Power(bool turnOn)
{
Console.WriteLine("Radio on: {0}", turnOn);
}
}
class Car
{
// Car 'имеет' Radio.
private Radio myRadio = new Radio();
public void TurnOnRadio(bool onOff)
{
// Делегировать вызов внутреннему объекту.
myRadio.Power(onOff);
}
}
Обратите внимание, что пользователю объекта ничего не известно об использовании классом
Car
внутреннего объекта Radio
:
// Call is forwarded to Radio internally.
Car viper = new Car();
viper.TurnOnRadio(false);
Последним основным принципом ООП является полиморфизм. Указанная характерная черта обозначает способность языка трактовать связанные объекты в сходной манере. В частности, данный принцип ООП позволяет базовому классу определять набор членов (формально называемый полиморфным интерфейсом), которые доступны всем наследникам. Полиморфный интерфейс класса конструируется с применением любого количества виртуальных или абстрактных членов (подробности ищите в главе 6).
Выражаясь кратко, виртуальный член — это член базового класса, определяющий стандартную реализацию, которую можно изменять (или более формально переопределять) в производном классе. В отличие от него абстрактный метод — это член базового класса, который не предоставляет стандартную реализацию, а предлагает только сигнатуру. Если класс унаследован от базового класса, в котором определен абстрактный метод, то такой метод должен быть переопределен в производном классе. В любом случае, когда производные классы переопределяют члены, определенные в базовом классе, по существу они переопределяют свою реакцию на тот же самый запрос.
Чтобы увидеть полиморфизм в действии, давайте предоставим некоторые детали иерархии фигур, показанной на рис. 5.3. Предположим, что в классе
Shape
определен виртуальный метод Draw()
, не принимающий параметров. С учетом того, что каждой фигуре необходимо визуализировать себя уникальным образом, подклассы вроде Hexagon
и Circle
могут переопределять метод Draw()
по своему усмотрению (см. рис. 5.3).
После того как полиморфный интерфейс спроектирован, можно начинать делать разнообразные предположения в коде. Например, так как классы
Hexagon
и Circle
унаследованы от общего родителя (Shape
), массив элементов типа Shape
может содержать любые объекты классов, производных от этого базового класса. Более того, поскольку класс Shape
определяет полиморфный интерфейс для всех производных типов (метод Draw()
в данном примере), уместно предположить, что каждый член массива обладает такой функциональностью.
Рассмотрим следующий код, который заставляет массив элементов производных от
Shape
типов визуализировать себя с использованием метода Draw()
:
Shape[] myShapes = new Shape[3];
myShapes[0] = new Hexagon();
myShapes[1] = new Circle();
myShapes[2] = new Hexagon();
foreach (Shape s in myShapes)
{
// Использовать полиморфный интерфейс!
s.Draw();
}
Console.ReadLine();
На этом краткий обзор основных принципов ООП завершен. Оставшийся материал главы посвящен дальнейшим подробностям поддержки инкапсуляции в языке С#, начиная с модификаторов доступа. Детали наследования и полиморфизма обсуждаются в главе 6.
При работе с инкапсуляцией вы должны всегда принимать во внимание то, какие аспекты типа являются видимыми различным частям приложения. В частности, типы (классы, интерфейсы, структуры, перечисления и делегаты), а также их члены (свойства, методы, конструкторы и поля) определяются с использованием специального ключевого слова, управляющего "видимостью" элемента для других частей приложения. Хотя в C# для управления доступом предусмотрены многочисленные ключевые слова, они отличаются в том, к чему могут успешно применяться (к типу или члену). Модификаторы доступа и особенности их использования описаны в табл. 5.1.
В текущей главе рассматриваются только ключевые слова
public
и private
. В последующих главах будет исследована роль модификаторов internal
и protected internal
(удобных при построении библиотек кода и модульных тестов) и модификатора protected
(полезного при создании иерархий классов).
По умолчанию члены типов являются неявно закрытыми (
private
), тогда как сами типы — неявно внутренними (internal
). Таким образом, следующее определение класса автоматически устанавливается как internal
, а стандартный конструктор типа — как private
(тем не менее, как и можно было предполагать, закрытые конструкторы классов нужны редко):
// Внутренний класс с закрытым стандартным конструктором.
class Radio
{
Radio(){}
}
Если вы предпочитаете явное объявление, тогда можете добавить соответствующие ключевые слова без каких-либо негативных последствий (помимо дополнительных усилий по набору):
// Внутренний класс с закрытым стандартным конструктором.
internal class Radio
{
private Radio(){}
}
Чтобы позволить другим частям программы обращаться к членам объекта, вы должны определить эти члены с ключевым словом
public
(или возможно с ключевым словом protected
, которое объясняется в следующей главе). Вдобавок, если вы хотите открыть доступ к Radio
внешним сборкам (что удобно при построении более крупных решений или библиотек кода), то к нему придется добавить модификатор public
:
// Открытый класс с открытым стандартным конструктором.
public class Radio
{
public Radio(){}
}
Как упоминалось в табл. 5.1, модификаторы доступа
private
, protected
, protected internal
и private protected
могут применяться к вложенному типу. Вложение типов будет подробно рассматриваться в главе 6, а пока достаточно знать, что вложенный тип — это тип, объявленный прямо внутри области видимости класса или структуры. В качестве примера ниже приведено закрытое перечисление (по имени CarColor
), вложенное в открытый класс (по имени SportsCar
):
public class SportsCar
{
// Нормально! Вложенные типы могут быть помечены как private.
private enum CarColor
{
Red, Green, Blue
}
}
Здесь допустимо применять модификатор доступа
private
к вложенному типу. Однако невложенные типы (вроде SportsCar
) могут определяться только с модификатором public
или internal
. Таким образом, следующее определение класса незаконно:
// Ошибка! Невложенный тип не может быть помечен как private!
public class Radio
{
public Radio(){}
}
Концепция инкапсуляции вращается вокруг идеи о том, что данные класса не должны быть напрямую доступными через его экземпляр. Наоборот, данные класса определяются как закрытые. Если пользователь объекта желает изменить его состояние, тогда он должен делать это косвенно, используя открытые члены. Чтобы проиллюстрировать необходимость в службах инкапсуляции, предположим, что создано такое определение класса:
// Класс с единственным открытым полем.
class Book
{
public int numberOfPages;
}
Проблема с открытыми данными заключается в том, что сами по себе они неспособны "понять", является ли присваиваемое значение допустимым с точки зрения текущих бизнес-правил системы. Как известно, верхний предел значений для типа
int
в C# довольно высок (2 147 483 647), поэтому компилятор разрешит следующее присваивание:
// Хм... Ничего себе мини-новелла!
Book miniNovel = new Book();
miniNovel.numberOfPages = 30_000_000;
Хотя границы типа данных
int
не превышены, понятно, что мини-новелла объемом 30 миллионов страниц выглядит несколько неправдоподобно. Как видите, открытые поля не предоставляют способа ограничения значений верхними (или нижними) логическими пределами. Если в системе установлено текущее бизнес-правило, которое регламентирует, что книга должна иметь от 1 до 1000 страниц, то совершенно неясно, как обеспечить его выполнение программным образом. Именно потому открытым полям обычно нет места в определениях классов производственного уровня.
На заметку! Говоря точнее, члены класса, которые представляют состояние объекта, не должны помечаться как
public
. В то же время позже в главе вы увидите, что вполне нормально иметь открытые константы и открытые поля, допускающие только чтение.
Инкапсуляция предлагает способ предохранения целостности данных состояния для объекта. Вместо определения открытых полей (которые могут легко привести к повреждению данных) необходимо выработать у себя привычку определять закрытые данные, управление которыми осуществляется опосредованно с применением одного из двух главных приемов:
• определение пары открытых методов доступа и изменения;
• определение открытого свойства.
Независимо от выбранного приема идея заключается в том, что хорошо инкапсулированный класс должен защищать свои данные и скрывать подробности своего функционирования от любопытных глаз из внешнего мира. Это часто называют программированием в стиле черного ящика. Преимущество такого подхода в том, что объект может свободно изменять внутреннюю реализацию любого метода. Работа существующего кода, который использует данный метод, не нарушается при условии, что параметры и возвращаемые значения методов остаются неизменными.
В оставшейся части главы будет построен довольно полный класс, моделирующий обычного сотрудника. Для начала создайте новый проект консольного приложения под названием
EmployeeApp
и добавьте в него новый файл класса по имени Employee.cs
. Обновите класс Employee
с применением следующего пространства имен, полей, методов и конструкторов:
using System;
namespace EmployeeApp
{
class Employee
{
// Поля данных.
private string _empName;
private int _empId;
private float _currPay;
// Конструкторы.
public Employee() {}
public Employee(string name, int id, float pay)
{
_empName = name;
_empId = id;
_currPay = pay;
}
// Методы.
public void GiveBonus(float amount) => _currPay += amount;
public void DisplayStats()
{
Console.WriteLine("Name: {0}", _empName); // имя сотрудника
Console.WriteLine("ID: {0}", _empId); // идентификационный
// номер сотрудника
Console.WriteLine("Pay: {0}", _currPay); // текущая выплата
}
}
}
Обратите внимание, что поля класса
Employee
в текущий момент определены с использованием ключевого слова private
. Учитывая это, поля empName
, empID
и currPay
не будут доступными напрямую через объектную переменную. Таким образом, показанная ниже логика в коде приведет к ошибкам на этапе компиляции:
Employee emp = new Employee();
// Ошибка! Невозможно напрямую обращаться к закрытым полям объекта!
emp._empName = "Marv";
Если нужно, чтобы внешний мир взаимодействовал с полным именем сотрудника, то традиционный подход предусматривает определение методов доступа (метод
get
) и изменения (метод set
). Роль метода get
заключается в возвращении вызывающему коду текущего значения лежащих в основе данных состояния. Метод set
позволяет вызывающему коду изменять текущее значение лежащих в основе данных состояния при условии удовлетворения бизнес-правил.
В целях иллюстрации давайте инкапсулируем поле
empName
, для чего к существующему классу Employee
необходимо добавить показанные ниже открытые методы. Обратите внимание, что метод SetName()
выполняет проверку входных данных, чтобы удостовериться, что строка имеет длину 15 символов или меньше. Если это не так, тогда на консоль выводится сообщение об ошибке и происходит возврат без внесения изменений в поле empName
.
На заметку! В случае класса производственного уровня проверку длины строки с именем сотрудника следовало бы предусмотреть также и внутри логики конструктора. Мы пока проигнорируем указанную деталь, но улучшим код позже, во время исследования синтаксиса свойств.
class Employee
{
// Поля данных.
private string _empName;
...
// Метод доступа (метод get).
public string GetName() => _empName;
// Метод изменения (метод set).
public void SetName(string name)
{
// Перед присваиванием проверить входное значение.
if (name.Length > 15)
{
Console.WriteLine("Error! Name length exceeds 15 characters!");
// Ошибка! Длина имени превышает 15 символов!
}
else
{
_empName = name;
}
}
}
Такой подход требует наличия двух уникально именованных методов для управления единственным элементом данных. Чтобы протестировать новые методы, модифицируйте свой код следующим образом:
Console.WriteLine("***** Fun with Encapsulation *****\n");
Employee emp = new Employee("Marvin", 456, 30_000);
emp.GiveBonus(1000);
emp.DisplayStats();
// Использовать методы get/set для взаимодействия
// с именем сотрудника, представленного объектом.
emp.SetName("Marv");
Console.WriteLine("Employee is named: {0}", emp.GetName());
Console.ReadLine();
Благодаря коду в методе
SetName()
попытка указать для имени строку, содержащую более 15 символов (как показано ниже), приводит к выводу на консоль жестко закодированного сообщения об ошибке:
Console.WriteLine("***** Fun with Encapsulation *****\n");
...
// Длиннее 15 символов! На консоль выводится сообщение об ошибке.
Employee emp2 = new Employee();
emp2.SetName("Xena the warrior princess");
Console.ReadLine();
Пока все идет хорошо. Мы инкапсулировали закрытое поле
empName
с использованием двух открытых методов с именами GetName()
и SetName()
. Для дальнейшей инкапсуляции данных в классе Employee
понадобится добавить разнообразные дополнительные методы (такие как GetID()
, SetID()
, GetCurrentPay()
, SetCurrentPay()
). В каждом методе, изменяющем данные, может содержаться несколько строк кода, в которых реализована проверка дополнительных бизнес-правил. Несмотря на то что это определенно достижимо, для инкапсуляции данных класса в языке C# имеется удобная альтернативная система записи.
Хотя инкапсулировать поля данных можно с применением традиционной пары методов
get
и set
, в языках .NET Core предпочтение отдается обеспечению инкапсуляции данных с использованием свойств. Прежде всего, имейте в виду, что свойства — всего лишь контейнер для "настоящих" методов доступа и изменения, именуемых get
и set
соответственно. Следовательно, проектировщик класса по-прежнему может выполнить любую внутреннюю логику перед присваиванием значения (например, преобразовать в верхний регистр, избавиться от недопустимых символов, проверить вхождение внутрь границ и т.д.).
Ниже приведен измененный код класса
Employee
, который теперь обеспечивает инкапсуляцию каждого поля с использованием синтаксиса свойств вместо традиционных методов get
и set
.
class Employee
{
// Поля данных.
private string _empName;
private int _empId;
private float _currPay;
// Свойства!
public string Name
{
get { return _empName; }
set
{
if (value.Length > 15)
{
Console.WriteLine("Error! Name length exceeds 15 characters!");
// Ошибка! Длина имени превышает 15 символов!
}
else
{
_empName = value;
}
}
}
// Можно было бы добавить дополнительные бизнес-правила для установки
// данных свойств, но в настоящем примере в этом нет необходимости.
public int Id
{
get { return _empId; }
set { _empId = value; }
}
public float Pay
{
get { return _currPay; }
set { _currPay = value; }
}
...
}
Свойство C# состоит из определений областей
get
(метод доступа) и set
(метод изменения) прямо внутри самого свойства. Обратите внимание, что свойство указывает тип инкапсулируемых им данных способом, который выглядит как возвращаемое значение. Кроме того, в отличие от метода при определении свойства не применяются круглые скобки (даже пустые). Взгляните на следующий комментарий к текущему свойству Id
:
// int представляет тип данных, инкапсулируемых этим свойством.
public int Id // Обратите внимание на отсутствие круглых скобок.
{
get { return _empId; }
set { _empID = value; }
}
В области видимости
set
свойства используется лексема value
, которая представляет входное значение, присваиваемое свойству вызывающим кодом. Лексема value
не является настоящим ключевым словом С#, а представляет собой то, что называется контекстным ключевым словом. Когда лексема value
находится внутри области set
, она всегда обозначает значение, присваиваемое вызывающим кодом, и всегда имеет тип, совпадающий с типом самого свойства. Таким образом, вот как свойство Name
может проверить допустимую длину строки:
public string Name
{
get { return _empName; }
set
{
// Здесь value на самом деле имеет тип string.
if (value.Length > 15)
{ Console.WriteLine("Error! Name length exceeds 15 characters!");
// Ошибка! Длина имени превышает 15 символов!
}
else
{
empName = value;
}
}
}
После определения свойств подобного рода вызывающему коду кажется, что он имеет дело с открытым элементом данных однако "за кулисами" при каждом обращении к ним вызывается корректный блок
get
или set
, предохраняя инкапсуляцию:
Console.WriteLine("***** Fun with Encapsulation *****\n");
Employee emp = new Employee("Marvin", 456, 30000);
emp.GiveBonus(1000);
emp.DisplayStats();
// Переустановка и аатем получение свойства Name.
emp.Name = "Marv";
Console.WriteLine("Employee is named: {0}", emp.Name); // имя сотрудника
Console.ReadLine();
Свойства (как противоположность методам доступа и изменения) также облегчают манипулирование типами, поскольку способны реагировать на внутренние операции С#. В целях иллюстрации будем считать, что тип класса
Employee
имеет внутреннюю закрытую переменную-член, представляющую возраст сотрудника. Ниже показаны необходимые изменения (обратите внимание на применение цепочки вызовов конструкторов):
class Employee
{
...
// Новое поле и свойство.
private int _empAge;
public int Age
{
get { return _empAge; }
set { _empAge = value; }
}
// Обновленные конструкторы.
public Employee() {}
public Employee(string name, int id, float pay)
:this(name, 0, id, pay){}
public Employee(string name, int age, int id, float pay)
{
_empName = name;
_empId = id;
_empAge = age;
_currPay = pay;
}
// Обновленный метод DisplayStats() теперь учитывает возраст.
public void DisplayStats()
{
Console.WriteLine("Name: {0}", _empName); // имя сотрудника
Console.WriteLine("ID: {0}", _empId);
// идентификационный номер сотрудника
Console.WriteLine("Age: {0}", _empAge); // возраст сотрудника
Console.WriteLine("Pay: {0}", _currPay); // текущая выплата
}
}
Теперь предположим, что создан объект
Employee
по имени joe
. Необходимо сделать так, чтобы в день рождения сотрудника возраст увеличивался на 1 год. Используя традиционные методы set
и get
, пришлось бы написать приблизительно такой код:
Employee joe = new Employee();
joe.SetAge(joe.GetAge() + 1);
Тем не менее, если
empAge
инкапсулируется посредством свойства по имени Age
, то код будет проще:
Employee joe = new Employee();
joe.Age++;
Как упоминалось ранее, методы
set
и get
свойств также могут записываться в виде членов, сжатых до выражений. Правила и синтаксис те же: однострочные методы могут быть записаны с применением нового синтаксиса. Таким образом, свойство Age
можно было бы переписать следующим образом:
public int Age
{
get => empAge;
set => empAge = value;
}
Оба варианта кода компилируются в одинаковый набор инструкций IL, поэтому выбор используемого синтаксиса зависит только от ваших предпочтений. В книге будут сочетаться оба стиля, чтобы подчеркнуть, что мы не придерживаемся какого-то специфического стиля написания кода.
Свойства, в частности их порция
set
, являются общепринятым местом для размещения бизнес-правил класса. В текущий момент класс Employee
имеет свойство Name
, которое гарантирует, что длина имени не превышает 15 символов. Остальные свойства (ID
, Рау
и Age
) также могут быть обновлены соответствующей логикой.
Хотя все это хорошо, но необходимо также принимать во внимание и то, что обычно происходит внутри конструктора класса. Конструктор получает входные параметры, проверяет данные на предмет допустимости и затем присваивает значения внутренним закрытым полям. Пока что главный конструктор не проверяет входные строковые данные на вхождение в диапазон допустимых значений, а потому его можно было бы изменить следующим образом:
public Employee(string name, int age, int id, float pay)
{
/// Похоже на проблему. ..
if (name.Length > 15)
{
Console.WriteLine("Error! Name length exceeds 15 characters!");
// Ошибка! Длина имени превышает 15 символов!
}
else
{
_empName = name;
}
_empId = id;
_empAge = age;
_currPay = pay;
}
Наверняка вы заметили проблему, связанную с таким подходом. Свойство
Name
и главный конструктор выполняют одну и ту же проверку на наличие ошибок. Реализуя проверки для других элементов данных, есть реальный шанс столкнуться с дублированием кода. Стремясь рационализировать код и изолировать всю проверку, касающуюся ошибок, в каком-то центральном местоположении, вы добьетесь успеха, если для получения и установки значений внутри класса всегда будете применять свойства. Взгляните на показанный ниже модифицированный конструктор:
public Employee(string name, int age, int id, float pay)
{
// Уже лучше! Используйте свойства для установки данных класса.
// Это сократит количество дублированных проверок на предмет ошибок.
Name = name;
Age = age;
ID = id;
Pay = pay;
}
Помимо обновления конструкторов для применения свойств при присваивании значений рекомендуется повсюду в реализации класса использовать свойства, чтобы гарантировать неизменное соблюдение бизнес-правил. Во многих случаях прямая ссылка на лежащие в основе закрытые данные производится только внутри самого свойства. Имея все сказанное в виду, модифицируйте класс
Employee
:
class Employee
{
// Поля данных.
private string _empName;
private int _empId;
private float _currPay;
private int _empAge;
// Конструкторы.
public Employee() { }
public Employee(string name, int id, float pay)
:this(name, 0, id, pay){}
public Employee(string name, int age, int id, float pay)
{
Name = name;
Age = age;
ID = id;
Pay = pay;
}
// Методы.
public void GiveBonus(float amount) => Pay += amount;
public void DisplayStats()
{
Console.WriteLine("Name: {0}", Name); // имя сотрудника
Console.WriteLine("ID: {0}", Id);
// идентификационный номер сотрудника
Console.WriteLine("Age: {0}", Age); // возраст сотрудника
Console.WriteLine("Pay: {0}", Pay); // текущая выплата
}
// Свойства остаются прежними...
...
}
При инкапсуляции данных может возникнуть желание сконфигурировать свойство, допускающее только чтение, для чего нужно просто опустить блок
set
. Например, пусть имеется новое свойство по имени SocialSecurityNumber
, которое инкапсулирует закрытую строковую переменную empSSN
. Вот как превратить его в свойство, доступное только для чтения:
public string SocialSecurityNumber
{
get { return _empSSN; }
}
Свойства, которые имеют только метод
get
, можно упростить с использованием членов, сжатых до выражений. Следующая строка эквивалентна предыдущему блоку кода:
public string SocialSecurityNumber => _empSSN;
Теперь предположим, что конструктор класса принимает новый параметр, который дает возможность указывать в вызывающем коде номер карточки социального страхования для объекта, представляющего сотрудника. Поскольку свойство
SocialSecurityNumber
допускает только чтение, устанавливать значение так, как показано ниже, нельзя:
public Employee(string name, int age, int id, float pay, string ssn)
{
Name = name;
Age = age;
ID = id;
Pay = pay;
// Если свойство предназначено только для чтения, это больше невозможно!
SocialSecurityNumber = ssn;
}
Если только вы не готовы переделать данное свойство в поддерживающее чтение и запись (что вскоре будет сделано), тогда единственным вариантом со свойствами, допускающими только чтение, будет применение лежащей в основе переменной-члена
empSSN
внутри логики конструктора:
public Employee(string name, int age, int id, float pay, string ssn)
{
...
// Проверить надлежащим образом входной параметр ssn
// и затем установить значение.
empSSN = ssn;
}
Если вы хотите сконфигурировать свойство как допускающее только запись, тогда опустите блок
get
, например:
public int Id
{
set { _empId = value; }
}
При определении свойств уровень доступа для методов
get
и set
может быть разным. Возвращаясь к номеру карточки социального страхования, если цель заключается в том, чтобы предотвратить модификацию номера извне класса, тогда объявите метод get
как открытый, но метод set
— как закрытый:
public string SocialSecurityNumber
{
get => _empSSN;
private set => _empSSN = value;
}
Обратите внимание, что это превращает свойство, допускающее только чтение, в допускающее чтение и запись. Отличие в том, что запись скрыта от чего-либо за рамками определяющего класса.
Ранее в главе рассказывалось о роли ключевого слова
static
. Теперь, когда вы научились использовать синтаксис свойств С#, мы можем формализовать статические свойства. В проекте StaticDataAndMembers
класс SavingsAccount
имел два открытых статических метода для получения и установки процентной ставки. Однако более стандартный подход предусматривает помещение такого элемента данных в статическое свойство. Ниже приведен пример (обратите внимание на применение ключевого слова static
):
// Простой класс депозитного счета.
class SavingsAccount
{
// Данные уровня экземпляра.
public double currBalance;
// Статический элемент данных.
private static double _currInterestRate = 0.04;
// Статическое свойство.
public static double InterestRate
{
get { return _currInterestRate; }
set { _currInterestRate = value; }
}
...
}
Если вы хотите использовать свойство
InterestRate
вместо предыдущих статических методов, тогда можете модифицировать свой код следующим образом:
// Вывести текущую процентную ставку через свойство.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.InterestRate);
Шаблон свойств позволяет сопоставлять со свойствами объекта. В качестве примера добавьте к проекту новый файл (
EmployeePayTypeEnum.cs
) и определите в нем перечисление для типов оплаты сотрудников:
namespace EmployeeApp
{
public enum EmployeePayTypeEnum
{
Hourly, // почасовая оплата
Salaried, // оклад
Commission // комиссионное вознаграждение
}
}
Обновите класс
Employee
, добавив свойство для типа оплаты и инициализировав его в конструкторе. Ниже показаны изменения, которые понадобится внести в код:
private EmployeePayTypeEnum _payType;
public EmployeePayTypeEnum PayType
{
get => _payType;
set => _payType = value;
}
public Employee(string name, int id, float pay, string empSsn)
: this(name,0,id,pay, empSsn, EmployeePayTypeEnum.Salaried)
{
}
public Employee(string name, int age, int id,
float pay, string empSsn, EmployeePayTypeEnum payType)
{
Name = name;
Id = id;
Age = age;
Pay = pay;
SocialSecurityNumber = empSsn;
PayType = payType;
}
Теперь, когда все элементы на месте, метод
GiveBonus()
можно обновить на основе типа оплаты сотрудника. Сотрудники с комиссионным вознаграждением получают премию 10%, с почасовой оплатой — 40-часовой эквивалент соответствующей премии, а с окладом — введенную сумму. Вот модифицированный код метода GiveBonus()
:
public void GiveBonus(float amount)
{
Pay = this switch
{
{PayType: EmployeePayTypeEnum.Commission }
=> Pay += .10F * amount,
{PayType: EmployeePayTypeEnum.Hourly }
=> Pay += 40F * amount/2080F,
{PayType: EmployeePayTypeEnum.Salaried }
=> Pay += amount,
_ => Pay+=0
};
}
Как и с другими операторами
switch
, в которых используется сопоставление с образцом, должен быть предусмотрен общий оператор case
или же оператор switch
обязан генерировать исключение, если ни один из операторов case
не был удовлетворен.
Чтобы протестировать внесенные обновления, добавьте к операторам верхнего уровня следующий код:
Employee emp = new Employee("Marvin",45,123,1000,"111-11-1111",
EmployeePayTypeEnum.
Salaried);
Console.WriteLine(emp.Pay);
emp.GiveBonus(100);
Console.WriteLine(emp.Pay);
При создании свойств для инкапсуляции данных часто обнаруживается, что области
set
содержат код для применения бизнес-правил программы. Тем не менее, в некоторых случаях нужна только простая логика извлечения или установки значения. В результате получается большой объем кода следующего вида:
// Тип Car, использующий стандартный синтаксис свойств.
class Car
{
private string carName = "";
public string PetName
{
get { return carName; }
set { carName = value; }
}
}
В подобных случаях многократное определение закрытых поддерживающих полей и простых свойств может стать слишком громоздким. Например, при построении класса, которому нужны девять закрытых элементов данных, в итоге получаются девять связанных с ними свойств, которые представляют собой не более чем тонкие оболочки для служб инкапсуляции.
Чтобы упростить процесс обеспечения простой инкапсуляции данных полей, можно использовать синтаксис автоматических свойств. Как следует из названия, это средство перекладывает работу по определению закрытых поддерживающих полей и связанных с ними свойств C# на компилятор за счет применения небольшого нововведения в синтаксисе. В целях иллюстрации создайте новый проект консольного приложения по имени
AutoProps
и добавьте к нему файл Car.cs
с переделанным классом Car
, в котором данный синтаксис используется для быстрого создания трех свойств:
using System;
namespace AutoProps
{
class Car
{
// Автоматические свойства! Нет нужды определять поддерживающие поля.
public string PetName { get; set; }
public int Speed { get; set; }
public string Color { get; set; }
}
}
На заметку! Среды Visual Studio и Visual Studio Code предоставляют фрагмент кода
prop
. Если вы наберете слово prop
внутри определения класса и нажмете клавишу <ТаЬ>, то IDE-среда сгенерирует начальный код для нового автоматического свойства. Затем с помощью клавиши <ТаЬ> можно циклически проходить по всем частям определения и заполнять необходимые детали. Испытайте описанный прием.
При определении автоматического свойства вы просто указываете модификатор доступа, лежащий в основе тип данных, имя свойства и пустые области
get/set
. Во время компиляции тип будет оснащен автоматически сгенерированным поддерживающим полем и подходящей реализацией логики get/set
.
На заметку! Имя автоматически сгенерированного закрытого поддерживающего поля будет невидимым для вашей кодовой базы С#. Просмотреть его можно только с помощью инструмента вроде
ildasm.exe
.
Начиная с версии C# 6, разрешено определять "автоматическое свойство только для чтения", опуская область
set
. Автоматические свойства только для чтения можно устанавливать только в конструкторе. Тем не менее, определять свойство, предназначенное только для записи, нельзя. Вот пример:
// Свойство только для чтения? Допустимо!
public int MyReadOnlyProp { get; }
// Свойство только для записи? Ошибка!
public int MyWriteOnlyProp { set; }
Поскольку компилятор будет определять закрытые поддерживающие поля на этапе компиляции (и учитывая, что эти поля в коде C# непосредственно не доступны), в классе, который имеет автоматические свойства, для установки и чтения лежащих в их основе значений всегда должен применяться синтаксис свойств. Указанный факт важно отметить, т.к. многие программисты напрямую используют закрытые поля внутри определения класса, что в данном случае невозможно. Например, если бы класс
Car
содержал метод DisplayStats()
, то в его реализации пришлось бы применять имена свойств:
class Car
{
// Автоматические свойства!
public string PetName { get; set; }
public int Speed { get; set; }
public string Color { get; set; }
public void DisplayStats()
{
Console.WriteLine("Car Name: {0}", PetName);
Console.WriteLine("Speed: {0}", Speed);
Console.WriteLine("Color: {0}", Color);
}
}
При использовании экземпляра класса, определенного с автоматическими свойствами, присваивать и получать значения можно с помощью вполне ожидаемого синтаксиса свойств:
using System;
using AutoProps;
Console.WriteLine("***** Fun with Automatic Properties *****\n");
Car c = new Car();
c.PetName = "Frank";
c.Speed = 55;
c.Color = "Red";
Console.WriteLine("Your car is named {0}? That's odd...",
c.PetName);
c.DisplayStats();
Console.ReadLine();
Когда автоматические свойства применяются для инкапсуляции числовых и булевских данных, их можно использовать прямо внутри кодовой базы, т.к. скрытым поддерживающим полям будут присваиваться безопасные стандартные значения (
false
для булевских и 0
для числовых данных). Но имейте в виду, что когда синтаксис автоматического свойства применяется для упаковки переменной другого класса, то скрытое поле ссылочного типа также будет установлено в стандартное значение null
(и это может привести к проблеме, если не проявить должную осторожность).
Добавьте к текущему проекту новый файл класса по имени
Garage
(представляющий гараж), в котором используются два автоматических свойства (разумеется, реальный класс гаража может поддерживать коллекцию объектов Car
; однако в данный момент проигнорируем такую деталь):
namespace AutoProps
{
class Garage
{
// Скрытое поддерживающее поле int установлено в О!
public int NumberOfCars { get; set; }
// Скрытое поддерживающее поле Car установлено в null!
public Car MyAuto { get; set; }
}
}
Имея стандартные значения C# для полей данных, значение
NumberOfCars
можно вывести в том виде, как есть (поскольку ему автоматически присвоено значение 0
). Но если напрямую обратиться к MyAuto
, то во время выполнения сгенерируется исключение ссылки на null
, потому что лежащей в основе переменной-члену типа Car
не был присвоен новый объект.
Garage g = new Garage();
// Нормально, выводится стандартное значение 0.
Console.WriteLine("Number of Cars: {0}", g.NumberOfCars);
// Ошибка во время выполнения!
// Поддерживающее поле в данный момент равно null!
Console.WriteLine(g.MyAuto.PetName);
Console.ReadLine();
Чтобы решить проблему, можно модифицировать конструкторы класса, обеспечив безопасное создание объекта. Ниже показан пример:
class Garage
{
// Скрытое поддерживающее поле установлено в 0!
public int NumberOfCars { get; set; }
// Скрытое поддерживающее поле установлено в null!
public Car MyAuto { get; set; }
// Для переопределения стандартных значений, присвоенных скрытым
// поддерживающим полям, должны использоваться конструкторы.
public Garage()
{
MyAuto = new Car();
NumberOfCars = 1;
}
public Garage(Car car, int number)
{
MyAuto = car;
NumberOfCars = number;
}
}
После такого изменения объект
Car
теперь можно помещать в объект Garage
:
Console.WriteLine("***** Fun with Automatic Properties *****\n");
// Создать объект автомобиля.
Car c = new Car();
c.PetName = "Frank";
c.Speed = 55;
c.Color = "Red";
c.DisplayStats();
// Поместить автомобиль в гараж.
Garage g = new Garage();
g.MyAuto = c;
// Вывести количество автомобилей в гараже
Console.WriteLine("Number of Cars in garage: {0}", g.NumberOfCars);
// Вывести название автомобиля.
Console.WriteLine("Your car is named: {0}", g.MyAuto.PetName);
Console.ReadLine();
Наряду с тем, что предыдущий подход работает вполне нормально, в версии C# 6 появилась языковая возможность, которая содействует упрощению способа присваивания автоматическим свойствам их начальных значений. Как упоминалось ранее в главе, полю данных в классе можно напрямую присваивать начальное значение при его объявлении. Например:
class Car
{
private int numberOfDoors = 2;
}
В похожей манере язык C# теперь позволяет присваивать начальные значения лежащим в основе поддерживающим полям, которые генерируются компилятором. В результате смягчаются трудности, присущие добавлению операторов кода в конструкторы класса, которые обеспечивают корректную установку данных свойств.
Ниже приведена модифицированная версия класса
Garage
с инициализацией автоматических свойств подходящими значениями. Обратите внимание, что больше нет необходимости в добавлении к стандартному конструктору класса логики для выполнения безопасного присваивания. В коде свойству MyAuto
напрямую присваивается новый объект Car
.
class Garage
{
// Скрытое поддерживающее поле установлено в 1.
public int NumberOfCars { get; set; } = 1;
// Скрытое поддерживающее поле установлено в новый объект Car.
public Car MyAuto { get; set; } = new Car();
public Garage(){}
public Garage(Car car, int number)
{
MyAuto = car;
NumberOfCars = number;
}
}
Наверняка вы согласитесь с тем, что автоматические свойства — очень полезное средство языка программирования С#, т.к. отдельные свойства в классе можно определять с применением модернизированного синтаксиса. Конечно, если вы создаете свойство, которое помимо получения и установки закрытого поддерживающего поля требует дополнительного кода (такого как логика проверки достоверности, регистрация в журнале событий, взаимодействие с базой данных и т.д.), то его придется определять как "нормальное" свойство .NET Core вручную. Автоматические свойства C# не делают ничего кроме обеспечения простой инкапсуляции для лежащей в основе порции (сгенерированных компилятором) закрытых данных.
На протяжении всей главы можно заметить, что при создании нового объекта конструктор позволяет указывать начальные значения. Вдобавок свойства позволяют безопасным образом получать и устанавливать лежащие в основе данные. При работе со сторонними классами, включая классы из библиотеки базовых классов .NET Core, нередко обнаруживается, что в них отсутствует конструктор, который позволял бы устанавливать абсолютно все порции данных состояния. В итоге программист обычно вынужден выбирать наилучший конструктор из числа возможных и затем присваивать остальные значения с использованием предоставляемого набора свойств.
Для упрощения процесса создания и подготовки объекта в C# предлагается синтаксис инициализации объектов. Такой прием делает возможным создание новой объектной переменной и присваивание значений многочисленным свойствам и/или открытым полям в нескольких строках кода. Синтаксически инициализатор объекта выглядит как список разделенных запятыми значений, помещенный в фигурные скобки (
{}
). Каждый элемент в списке инициализации отображается на имя открытого поля или открытого свойства инициализируемого объекта.
Чтобы увидеть данный синтаксис в действии, создайте новый проект консольного приложения по имени
ObjectInitializers
. Ниже показан класс Point
, в котором присутствуют автоматические свойства (для синтаксиса инициализации объектов они не обязательны, но помогают получить более лаконичный код):
class Point
{
public int X { get; set; }
public int Y { get; set; }
public Point(int xVal, int yVal)
{
X = xVal;
Y = yVal;
}
public Point() { }
public void DisplayStats()
{
Console.WriteLine("[{0}, {1}]", X, Y);
}
}
А теперь посмотрим, как создавать объекты
Point
, с применением любого из следующих подходов:
Console.WriteLine("***** Fun with Object Init Syntax *****\n");
// Создать объект Point, устанавливая каждое свойство вручную.
Point firstPoint = new Point();
firstPoint.X = 10;
firstPoint.Y = 10;
firstPoint.DisplayStats();
// Или создать объект Point посредством специального конструктора.
Point anotherPoint = new Point(20, 20);
anotherPoint.DisplayStats();
// Или создать объект Point, используя синтаксис инициализации объектов.
Point finalPoint = new Point { X = 30, Y = 30 };
finalPoint.DisplayStats();
Console.ReadLine();
При создании последней переменной
Point
специальный конструктор не используется (как делается традиционно), а взамен устанавливаются значения открытых свойств X
и Y
. "За кулисами" вызывается стандартный конструктор типа, за которым следует установка значений указанных свойств. В таком отношении синтаксис инициализации объектов представляет собой просто сокращение синтаксиса для создания переменной класса с применением стандартного конструктора и установки данных состояния свойство за свойством.
На заметку! Важно помнить о том, что процесс инициализации объектов неявно использует методы установки свойств. Если метод установки какого-то свойства помечен как
private
, тогда этот синтаксис применить не удастся.
В версии C# 9.0 появилось новое средство доступа только для инициализации. Оно позволяет устанавливать свойство во время инициализации, но после завершения конструирования объекта свойство становится доступным только для чтения. Свойства такого типа называются неизменяемыми. Добавьте к проекту новый файл класса по имени
ReadOnlyPointAfterCreation.cs
и поместите в него следующий код:
using System;
namespace ObjectInitializers
{
class PointReadOnlyAfterCreation
{
public int X { get; init; }
public int Y { get; init; }
public void DisplayStats()
{
Console.WriteLine("InitOnlySetter: [{0}, {1}]", X, Y);
}
public PointReadOnlyAfterCreation(int xVal, int yVal)
{
X = xVal;
Y = yVal;
}
public PointReadOnlyAfterCreation() { }
}
}
Новый класс тестируется с применением приведенного ниже кода:
// Создать объект точки, допускающий только чтение
// после конструирования
PointReadOnlyAfterCreation firstReadonlyPoint =
new PointReadOnlyAfterCreation(20, 20);
firstReadonlyPoint.DisplayStats();
// Или создать объект точки с использованием синтаксиса только
// для инициализации.
PointReadOnlyAfterCreation secondReadonlyPoint =
new PointReadOnlyAfterCreation { X = 30, Y
= 30 };
secondReadonlyPoint.DisplayStats();
Обратите внимание, что в коде для класса
Point
ничего не изменилось кроме, разумеется, имени класса. Отличие в том, что после создания экземпляра класса модифицировать значения свойств X
и Y
нельзя. Например, показанный далее код не скомпилируется:
// Следующие две строки не скомпилируются
secondReadonlyPoint.X = 10;
secondReadonlyPoint.Y = 10;
В предшествующих примерах объекты типа
Point
инициализировались путем неявного вызова стандартного конструктора этого типа:
// Здесь стандартный конструктор вызывается неявно.
Point finalPoint = new Point { X = 30, Y = 30 };
При желании стандартный конструктор допускается вызывать и явно:
// Здесь стандартный конструктор вызывается явно.
Point finalPoint = new Point() { X = 30, Y = 30 };
Имейте в виду, что при конструировании объекта типа с использованием синтаксиса инициализации можно вызывать любой конструктор, определенный в классе. В настоящий момент в типе
Point
определен конструктор с двумя аргументами для установки позиции (х, у). Таким образом, следующее объявление переменной Point
приведет к установке X
в 100
и Y
в 100
независимо от того факта, что в аргументах конструктора указаны значения 10
и 16
:
// Вызов специального конструктора.
Point pt = new Point(10, 16) { X = 100, Y = 100 };
Имея текущее определение типа
Point
, вызов специального конструктора с применением синтаксиса инициализации не особенно полезен (и излишне многословен). Тем не менее, если тип Point
предоставляет новый конструктор, который позволяет вызывающему коду устанавливать цвет (через специальное перечисление PointColor
), тогда комбинация специальных конструкторов и синтаксиса инициализации объектов становится ясной.
Добавьте к проекту новый файл класса по имени
PointColorEnum.cs
и создайте следующее перечисление цветов:
namespace ObjectInitializers
{
enum PointColorEnum
{
LightBlue,
BloodRed,
Gold
}
}
Обновите код класса
Point
, как показано ниже:
class Point
{
public int X { get; set; }
public int Y { get; set; }
public PointColorEnum Color{ get; set; }
public Point(int xVal, int yVal)
{
X = xVal;
Y = yVal;
Color = PointColorEnum.Gold;
}
public Point(PointColorEnum ptColor)
{
Color = ptColor;
}
public Point() : this(PointColorEnum.BloodRed){ }
public void DisplayStats()
{
Console.WriteLine("[{0}, {1}]", X, Y);
Console.WriteLine("Point is {0}", Color);
}
}
Посредством нового конструктора теперь можно создавать точку золотистого цвета (в позиции (90, 20)):
// Вызов более интересного специального конструктора
// с помощью синтаксиса инициализации.
Point goldPoint = new Point(PointColorEnum.Gold){ X = 90, Y = 20 };
goldPoint.DisplayStats();
Как кратко упоминалось ранее в главе (и будет подробно обсуждаться в главе 6), отношение "имеет" позволяет формировать новые классы, определяя переменные-члены существующих классов. Например, пусть определен класс
Rectangle
, в котором для представления координат верхнего левого и нижнего правого углов используется тип Point
. Так как автоматические свойства устанавливают все переменные с типами классов в null
, новый класс будет реализован с применением "традиционного" синтаксиса свойств:
using System;
namespace ObjectInitializers
{
class Rectangle
{
private Point topLeft = new Point();
private Point bottomRight = new Point();
public Point TopLeft
{
get { return topLeft; }
set { topLeft = value; }
}
public Point BottomRight
{
get { return bottomRight; }
set { bottomRight = value; }
}
public void DisplayStats()
{
Console.WriteLine("[TopLeft: {0}, {1}, {2} BottomRight: {3},
{4}, {5}]",
topLeft.X, topLeft.Y, topLeft.Color,
bottomRight.X, bottomRight.Y, bottomRight.Color);
}
}
}
С помощью синтаксиса инициализации объектов можно было бы создать новую переменную
Rectangle
и установить внутренние объекты Point
следующим образом:
// Создать и инициализировать объект Rectangle.
Rectangle myRect = new Rectangle
{
TopLeft = new Point { X = 10, Y = 10 },
BottomRight = new Point { X = 200, Y = 200}
};
Преимущество синтаксиса инициализации объектов в том, что он по существу сокращает объем вводимого кода (предполагая отсутствие подходящего конструктора). Вот как выглядит традиционный подход к созданию похожего экземпляра
Rectangle
:
// Традиционный подход.
Rectangle r = new Rectangle();
Point p1 = new Point();
p1.X = 10;
p1.Y = 10;
r.TopLeft = p1;
Point p2 = new Point();
p2.X = 200;
p2.Y = 200;
r.BottomRight = p2;
Поначалу синтаксис инициализации объектов может показаться несколько непривычным, но как только вы освоитесь с кодом, то будете приятно поражены тем, насколько быстро и с минимальными усилиями можно устанавливать состояние нового объекта.
Иногда требуется свойство, которое вы вообще не хотите изменять либо с момента компиляции, либо с момента его установки во время конструирования, также известное как неизменяемое. Один пример уже был исследован ранее — средства доступа только для инициализации. А теперь мы займемся константными полями и полями, допускающими только чтение.
Язык C# предлагает ключевое слово
const
, предназначенное для определения константных данных, которые после начальной установки больше никогда не могут быть изменены. Как нетрудно догадаться, оно полезно при определении набора известных значений для использования в приложениях, логически связанных с заданным классом или структурой.
Предположим, что вы строите обслуживающий класс по имени
MyMathClass
, в котором нужно определить значение числа π
(для простоты будем считать его равным 3.14
). Начните с создания нового проекта консольного приложения по имени ConstData
и добавьте к нему файл класса MyMathClass.cs
. Учитывая, что давать возможность другим разработчикам изменять это значение в коде нежелательно, число π
можно смоделировать с помощью следующей константы:
//MyMathClass.cs
using System;
namespace ConstData
{
class MyMathClass
{
public const double PI = 3.14;
}
}
Приведите код в файле
Program.cs
к следующему виду:
using System;
using ConstData;
Console.WriteLine("***** Fun with Const *****\n");
Console.WriteLine("The value of PI is: {0}", MyMathClass.PI);
// Ошибка! Константу изменять нельзя!
// MyMathClass.PI = 3.1444;
Console.ReadLine();
Обратите внимание, что ссылка на константные данные, определенные в классе
MyMathClass
, производится с применением префикса в виде имени класса (т.е. MyMathClass.PI
). Причина в том, что константные поля класса являются неявно статическими. Однако допустимо определять локальные константные данные и обращаться к ним внутри области действия метода или свойства, например:
static void LocalConstStringVariable()
{
// Доступ к локальным константным данным можно получать напрямую.
const string fixedStr = "Fixed string Data";
Console.WriteLine(fixedStr);
// Ошибка!
// fixedStr = "This will not work!";
}
Независимо от того, где вы определяете константную часть данных, всегда помните о том, что начальное значение, присваиваемое константе, должно быть указано в момент ее определения. Присваивание значения
РI
внутри конструктора класса приводит к ошибке на этапе компиляции:
class MyMathClass
{
// Попытка установить PI в конструкторе?
public const double PI;
public MyMathClass()
{
// Невозможно - присваивание должно осуществляться в момент объявления.
PI = 3.14;
}
}
Такое ограничение связано с тем фактом, что значение константных данных должно быть известно на этапе компиляции. Как известно, конструкторы (или любые другие методы) вызываются во время выполнения.
С константными данными тесно связано понятие полей данных, допускающих только чтение (которое не следует путать со свойствами, доступными только для чтения). Подобно константе поле только для чтения нельзя изменять после первоначального присваивания, иначе вы получите ошибку на этапе компиляции. Тем не менее, в отличие от константы значение, присваиваемое такому полю, может быть определено во время выполнения и потому может на законном основании присваиваться внутри конструктора, но больше нигде.
Поле только для чтения полезно в ситуации, когда значение не известно вплоть до стадии выполнения (возможно из-за того, что для его получения необходимо прочитать внешний файл), но нужно гарантировать, что впоследствии оно не будет изменяться. В целях иллюстрации рассмотрим следующую модификацию класса
MyMathClass
:
class MyMathClass
{
// Поля только для чтения могут присваиваться
// в конструкторах, но больше нигде.
public readonly double PI;
public MyMathClass ()
{
PI = 3.14;
}
}
Любая попытка выполнить присваивание полю, помеченному как
readonly
, за пределами конструктора приведет к ошибке на этапе компиляции:
class MyMathClass
{
public readonly double PI;
public MyMathClass ()
{
PI = 3.14;
}
// Ошибка!
public void ChangePI()
{ PI = 3.14444;}
}
В отличие от константных полей поля, допускающие только чтение, не являются неявно статическими. Таким образом, если необходимо предоставить доступ к
PI
на уровне класса, то придется явно использовать ключевое слово static
. Если значение статического поля только для чтения известно на этапе компиляции, тогда начальное присваивание выглядит очень похожим на такое присваивание в случае константы (однако в этой ситуации проще применить ключевое слово const
, потому что поле данных присваивается в момент его объявления):
class MyMathClass
{
public static readonly double PI = 3.14;
}
// Program.cs
Console.WriteLine("***** Fun with Const *****");
Console.WriteLine("The value of PI is: {0}", MyMathClass.PI);
Console.ReadLine();
Тем не менее, если значение статического поля только для чтения не известно вплоть до времени выполнения, то должен использоваться статический конструктор, как было описано ранее в главе:
class MyMathClass
{
public static readonly double PI;
static MyMathClass()
{ PI = 3.14; }
}
При работе с классами важно понимать роль ключевого слова
partial
языка С#. Ключевое слово partial
позволяет разбить одиночный класс на множество файлов кода. Когда вы создаете шаблонные классы Entity Framework Core из базы данных, то все полученные в результате классы будут частичными. Таким образом, любой код, который вы написали для дополнения этих файлов, не будет перезаписан при условии, что код находится в отдельных файлах классов, помеченных с помощью ключевого слова partial
. Еще одна причина связана с тем, что ваш класс может со временем разрастись и стать трудным в управлении, и в качестве промежуточного шага к его рефакторингу вы разбиваете код на части.
В языке C# одиночный класс можно разносить по нескольким файлам кода для отделения стереотипного кода от более полезных (и сложных) членов. Чтобы ознакомиться с ситуацией, когда частичные классы могут быть удобными, загрузите ранее созданный проект
EmployееАрр
в Visual Studio и откройте файл Employee.cs
для редактирования. Как вы помните, этот единственный файл содержит код для всех аспектов класса:
class Employee
{
// Поля данных
// Конструкторы
// Методы
// Свойства
}
С применением частичных классов вы могли бы перенести (скажем) свойства, конструкторы и поля данных в новый файл по имени
Employee.Core.cs
(имя файла к делу не относится). Первый шаг предусматривает добавление ключевого слова partial
к текущему определению класса и вырезание кода, подлежащего помещению в новый файл:
// Employee.cs
partial class Employee
{
// Методы
// Свойства
}
Далее предположив, что к проекту был добавлен новый файл класса, в него можно переместить поля данных и конструкторы с помощью простой операции вырезания и вставки. Кроме того, вы обязаны добавить ключевое слово
partial
к этому аспекту определения класса. Вот пример:
// Employee.Core.cs
partial class Employee
{
// Поля данных
// Свойства
}
На заметку! Не забывайте, что каждый частичный класс должен быть помечен ключевым словом
partial
!
После компиляции модифицированного проекта вы не должны заметить вообще никакой разницы. Вся идея, положенная в основу частичного класса, касается только стадии проектирования. Как только приложение скомпилировано, в сборке оказывается один целостный класс. Единственное требование при определении частичных классов связано с тем, что разные части должны иметь одно и то же имя класса и находиться внутри того же самого пространства имен .NET Core.
В версии C# 9.0 появился особый вид классов — записи. Записи являются ссылочными типами, которые предоставляют синтезированные методы с целью обеспечения семантики значений для эквивалентности. По умолчанию типы записей неизменяемы. Хотя по существу дела вы могли бы создать неизменяемый класс, но с применением комбинации средств доступа только для инициализации и свойств, допускающих только чтение, типы записей позволяют избавиться от такой дополнительной работы.
Чтобы приступить к экспериментам с записями, создайте новый проект консольного приложения по имени
FunWithRecords
. Измените код класса Car
из примеров, приведенных ранее в главе:
class Car
{
public string Make { get; set; }
public string Model { get; set; }
public string Color { get; set; }
public Car() {}
public Car(string make, string model, string color)
{
Make = make;
Model = model;
Color = color;
}
}
Как вы уже хорошо знаете, после создания экземпляра этого класса вы можете изменять любое свойство во время выполнения. Если каждый экземпляр должен быть неизменяемым, тогда можете модифицировать определения свойств следующим образом:
public string Make { get; init; }
public string Model { get; init; }
public string Color { get; init; }
Для использования нового класса
Car
в показанном ниже коде из файла Program.cs
создаются два его экземпляра — один через инициализацию объекта, а другой посредством специального конструктора:
using System;
using FunWithRecords;
Console.WriteLine("Fun with Records!");
// Использовать инициализацию объекта
Car myCar = new Car
{
Make = "Honda",
Model = "Pilot",
Color = "Blue"
};
Console.WriteLine("My car: ");
DisplayCarStats(myCar);
Console.WriteLine();
// Использовать специальный конструктор
Car anotherMyCar = new Car("Honda", "Pilot", "Blue");
Console.WriteLine("Another variable for my car: ");
DisplayCarStats(anotherMyCar);
Console.WriteLine();
// Попытка изменения свойства приводит к ошибке на этапе компиляции.
// myCar.Color = "Red";
Console.ReadLine();
static void DisplayCarStats(Car c)
{
Console.WriteLine("Car Make: {0}", c.Make);
Console.WriteLine("Car Model: {0}", c.Model);
Console.WriteLine("Car Color: {0}", c.Color);
}
Вполне ожидаемо оба метода создания объекта работают, значения свойств отображаются, а попытка изменить свойство после конструирования приводит к ошибке на этапе компиляции.
Чтобы создать тип записи
CarRecord
, добавьте к проекту новый файл по имени CarRecord.cs
со следующим кодом:
record CarRecord
{
public string Make { get; init; }
public string Model { get; init; }
public string Color { get; init; }
public CarRecord () {}
public CarRecord (string make, string model, string color)
{
Make = make;
Model = model;
Color = color;
}
}
Запустив приведенный далее код из
Program.cs
, вы можете удостовериться в том, что поведение записи CarRecord
будет таким же, как у класса Car
со средствами доступа только для инициализации:
Console.WriteLine("/*************** RECORDS *********************/");
// Использовать инициализацию объекта
CarRecord myCarRecord = new CarRecord
{
Make = "Honda",
Model = "Pilot",
Color = "Blue"
};
Console.WriteLine("My car: ");
DisplayCarRecordStats(myCarRecord);
Console.WriteLine();
// Использовать специальный конструктор
CarRecord anotherMyCarRecord = new CarRecord("Honda", "Pilot", "Blue");
Console.WriteLine("Another variable for my car: ");
Console.WriteLine(anotherMyCarRecord.ToString());
Console.WriteLine();
// Попытка изменения свойства приводит к ошибке на этапе компиляции.
// myCarRecord . Color = "Red";
Console.ReadLine();
Хотя мы пока еще не обсуждали эквивалентность (см. следующий раздел) или наследование (см. следующую главу) с типами записей, первое знакомство с записями не создает впечатления, что они обеспечивают большое преимущество. Текущий пример записи
CarRecord
включал весь ожидаемый связующий код. Заметное отличие присутствует в выводе: метод ToString()
для типов записей более причудлив, как видно в показанном ниже фрагменте вывода:
/*************** RECORDS *********************/
My car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }
Another variable for my car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }
Но взгляните на следующее обновленное определение записи
Car
:
record CarRecord(string Make, string Model, string Color);
В конструкторе так называемого позиционного типа записи определены свойства записи, а весь остальной связующий код удален. При использовании такого синтаксиса необходимо принимать во внимание три соображения. Во-первых, не разрешено применять инициализацию объектов типов записей, использующих компактный синтаксис определения, во-вторых, запись должна конструироваться со свойствами, расположенными в корректных позициях, и, в-третьих, регистр символов в свойствах конструктора точно повторяется в свойствах внутри типа записи.
В примере класса
Car
два экземпляра Car
создавались с одними и теми же данными. В приведенной далее проверке может показаться, что следующие два экземпляра класса эквивалентны:
Console.WriteLine($"Cars are the same? {myCar.Equals(anotherMyCar)}");
// Эквивалентны ли экземпляры Car?
Однако они не эквивалентны. Вспомните, что типы записей представляют собой специализированный вид класса, а классы являются ссылочными типами. Чтобы два ссылочных типа были эквивалентными, они должны указывать на тот же самый объект в памяти. В качестве дальнейшей проверки выясним, указывают ли два экземпляра
Car
на тот же самый объект:
Console.WriteLine($"Cars are the same reference?
{ReferenceEquals(myCar, anotherMyCar)}");
// Указывают ли экземпляры Car на тот же самый объект?
Запуск программы дает приведенный ниже результат:
Cars are the same? False
CarRecords are the same? False
Типы записей ведут себя по-другому. Они неявно переопределяют
Equals()
, ==
и !=
, чтобы производить результаты, как если бы экземпляры были типами значений. Взгляните на следующий код и показанные далее результаты:
Console.WriteLine($"CarRecords are the same?
{myCarRecord.Equals(anotherMyCarRecord)}");
// Эквивалентны ли экземпляры CarRecord?
Console.WriteLine($"CarRecords are the same reference?
{ReferenceEquals(myCarRecord,another
MyCarRecord)}");
// Указывают ли экземпляры CarRecord на тот же самый объект?
Console.WriteLine($"CarRecords are the same?
{myCarRecord == anotherMyCarRecord}");
Console.WriteLine($"CarRecords are not the same?
{myCarRecord != anotherMyCarRecord}");
Вот результирующий вывод:
/*************** RECORDS *********************/
My car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }
Another variable for my car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }
CarRecords are the same? True
CarRecords are the same reference? false
CarRecords are the same? True
CarRecords are not the same? False
Обратите внимание, что записи считаются эквивалентными, невзирая на то, что переменные указывают на два разных объекта в памяти.
Для типов записей присваивание экземпляра такого типа новой переменной создает указатель на ту же самую ссылку, что аналогично поведению классов. Это демонстрируется в приведенном ниже коде:
CarRecord carRecordCopy = anotherMyCarRecord;
Console.WriteLine("Car Record copy results");
Console.WriteLine($"CarRecords are the same?
{carRecordCopy.Equals(anotherMyCarRecord)}");
Console.WriteLine($"CarRecords are the same?
{ReferenceEquals(carRecordCopy,
anotherMyCarRecord)}");
В результате запуска кода обе проверки возвращают
True
, доказывая эквивалентность по значению и по ссылке.
Для создания подлинной копии записи с модифицированным одним или большим числом свойств в версии C# 9.0 были введены выражения
with
. В конструкции with
указываются любые подлежащие обновлению свойства вместе с их новыми значениями, а значения свойств, которые не были перечислены, копируются без изменений. Вот пример:
CarRecord ourOtherCar = myCarRecord with {Model = "Odyssey"};
Console.WriteLine("My copied car:");
Console.WriteLine(ourOtherCar.ToString());
Console.WriteLine("Car Record copy using with expression results");
// Результаты копирования CarRecord
// с использованием выражения with
Console.WriteLine($"CarRecords are the same?
{ourOtherCar.Equals(myCarRecord)}");
Console.WriteLine($"CarRecords are the same?
{ReferenceEquals(ourOtherCar, myCarRecord)}");
В коде создается новый экземпляр типа
CarRecord
с копированием значений Make
и Color
экземпляра myCarRecord
и установкой Model
в строку "Odyssey"
. Ниже показаны результаты выполнения кода:
/*************** RECORDS *********************/
My copied car:
CarRecord { Make = Honda, Model = Odyssey, Color = Blue }
Car Record copy using with expression results
CarRecords are the same? False
CarRecords are the same? False
С применением выражений
with
вы можете компоновать экземпляры типов записей в новые экземпляры типов записей с модифицированными значениями свойств. На этом начальное знакомство с новыми типами записей C# 9.0 завершено. В следующей главе будут подробно исследоваться типы записей и наследование.
Целью главы было ознакомление вас с ролью типа класса C# и нового типа записи C# 9.0. Вы видели, что классы могут иметь любое количество конструкторов, которые позволяют пользователю объекта устанавливать состояние объекта при его создании. В главе также было продемонстрировано несколько приемов проектирования классов (и связанных с ними ключевых слов). Ключевое слово
this
используется для получения доступа к текущему объекту. Ключевое слово static
дает возможность определять поля и члены, привязанные к уровню класса (не объекта). Ключевое слово const
, модификатор readonly
и средства доступа только для инициализации позволяют определять элементы данных, которые никогда не изменяются после первоначальной установки или конструирования объекта. Типы записей являются особым видом класса, который неизменяем и при сравнении одного экземпляра типа записи с другим экземпляром того же самого типа записи ведет себя подобно типам значений.
Большая часть главы была посвящена деталям первого принципа ООП — инкапсуляции. Вы узнали о модификаторах доступа C# и роли свойств типа, о синтаксисе инициализации объектов и о частичных классах. Теперь вы готовы перейти к чтению следующей главы, в которой речь пойдет о построении семейства взаимосвязанных классов с применением наследования и полиморфизма.
В главе 5 рассматривался первый основной принцип объектно-ориентированного программирования (ООП) — инкапсуляция. Вы узнали, как строить отдельный четко определенный тип класса с конструкторами и разнообразными членами (полями, свойствами, методами, константами и полями только для чтения). В настоящей главе мы сосредоточим внимание на оставшихся двух принципах ООП: наследовании и полиморфизме.
Прежде всего, вы научитесь строить семейства связанных классов с применением наследования. Как будет показано, такая форма многократного использования кода позволяет определять в родительском классе общую функциональность, которая может быть задействована, а возможно и модифицирована в дочерних классах. В ходе изложения вы узнаете, как устанавливать полиморфный интерфейс в иерархиях классов, используя виртуальные и абстрактные члены, а также о роли явного приведения.
Глава завершится исследованием роли изначального родительского класса в библиотеках базовых классов .NET Core —
System.Object
.
Вспомните из главы 5, что наследование — это аспект ООП, упрощающий повторное использование кода. Говоря более точно, встречаются две разновидности повторного использования кода: наследование (отношение "является") и модель включения/делегации (отношение "имеет"). Давайте начнем текущую главу с рассмотрения классической модели наследования, т.е. отношения "является".
Когда вы устанавливаете между классами отношение "является", то тем самым строите зависимость между двумя и более типами классов. Основная идея, лежащая в основе классического наследования, состоит в том, что новые классы могут создаваться с применением существующих классов как отправной точки. В качестве простого примера создайте новый проект консольного приложения по имени
BasicInheritance
.
Предположим, что вы спроектировали класс
Car
, который моделирует ряд базовых деталей автомобиля:
namespace BasicInheritance
{
// Простой базовый класс.
class Car
{
public readonly int MaxSpeed;
private int _currSpeed;
public Car(int max)
{
MaxSpeed = max;
}
public Car()
{
MaxSpeed = 55;
}
public int Speed
{
get { return _currSpeed; }
set
{
_currSpeed = value;
if (_currSpeed > MaxSpeed)
{
_currSpeed = MaxSpeed;
}
}
}
}
}
Обратите внимание, что класс
Car
использует службы инкапсуляции для управления доступом к закрытому полю _currSpead
посредством открытого свойства по имени Speed
. В данный момент с типом Car
можно работать следующим образом:
using System;
using BasicInheritance;
Console.WriteLine("***** Basic Inheritance *****\n");
// Создать объект Car и установить максимальную и текущую скорости.
Car myCar = new Car(80) {Speed = 50};
// Вывести значение текущей скорости.
Console.WriteLine("My car is going {0} MPH", myCar.Speed);
Console.ReadLine();
Теперь предположим, что планируется построить новый класс по имени
MiniVan
. Подобно базовому классу Car
вы хотите определить класс MiniVan
так, чтобы он поддерживал данные для максимальной и текущей скоростей и свойство по имени Speed
, которое позволило бы пользователю модифицировать состояние объекта. Очевидно, что классы Car
и MiniVan
взаимосвязаны; фактически можно сказать, что MiniVan
"является" разновидностью Car
. Отношение "является" (формально называемое классическим наследованием) позволяет строить новые определения классов, которые расширяют функциональность существующих классов.
Существующий класс, который будет служить основой для нового класса, называется базовым классом, суперклассом или родительским классом. Роль базового класса заключается в определении всех общих данных и членов для классов, которые его расширяют. Расширяющие классы формально называются производными или дочерними классами. В языке C# для установления между классами отношения "является" применяется операция двоеточия в определении класса. Пусть вы написали новый класс
MiniVan
следующего вида:
namespace BasicInheritance
{
// MiniVan "является" Car.
sealed class MiniVan : Car
{
}
}
В текущий момент никаких членов в новом классе не определено. Так чего же мы достигли за счет наследования
MiniVan
от базового класса Car
? Выражаясь просто, объекты MiniVan
теперь имеют доступ ко всем открытым членам, определенным внутри базового класса.
На заметку! Несмотря на то что конструкторы обычно определяются как открытые, производный класс никогда не наследует конструкторы родительского класса. Конструкторы используются для создания только экземпляра класса, внутри которого они определены, но к ним можно обращаться в производном классе через построение цепочки вызовов конструкторов, как будет показано далее.
Учитывая отношение между этими двумя типами классов, вот как можно работать с классом
MiniVan
:
Console.WriteLine("***** Basic Inheritance *****\n");
...
// Создать объект MiniVan.
MiniVan myVan = new MiniVan {Speed = 10};
Console.WriteLine("My van is going {0} MPH", myVan.Speed);
Console.ReadLine();
Обратите внимание, что хотя в класс
MiniVan
никакие члены не добавлялись, в нем есть прямой доступ к открытому свойству Speed
родительского класса; тем самым обеспечивается повторное использование кода. Такой подход намного лучше, чем создание класса MiniVan
, который имеет те же самые члены, что и класс Car
, скажем, свойство Speed
. Дублирование кода в двух классах приводит к необходимости сопровождения двух порций кода, что определенно будет непродуктивным расходом времени.
Всегда помните о том, что наследование предохраняет инкапсуляцию, а потому следующий код вызовет ошибку на этапе компиляции, т.к. закрытые члены не могут быть доступны через объектную ссылку:
Console.WriteLine("***** Basic Inheritance *****\n");
...
// Создать объект MiniVan.
MiniVan myVan = new MiniVan();
myVan.Speed = 10;
Console.WriteLine("My van is going {0} MPH",
myVan.Speed);
// Ошибка! Доступ к закрытым членам невозможен!
myVan._currSpeed = 55;
Console.ReadLine();
В качестве связанного примечания: даже когда класс
MiniVan
определяет собственный набор членов, он по-прежнему не будет располагать возможностью доступа к любым закрытым членам базового класса Car
. Не забывайте, что закрытые члены доступны только внутри класса, в котором они определены. Например, показанный ниже метод в MiniVan
приведет к ошибке на этапе компиляции:
// Класс MiniVan является производным от Car.
class MiniVan : Car
{
public void TestMethod()
{
// Нормально! Доступ к открытым членам родительского
// типа в производном типе возможен.
Speed = 10;
// Ошибка! Нельзя обращаться к закрытым членам
// родительского типа из производного типа!
_currSpeed = 10;
}
}
Говоря о базовых классах, важно иметь в виду, что язык C# требует, чтобы отдельно взятый класс имел в точности один непосредственный базовый класс. Создать тип класса, который был бы производным напрямую от двух и более базовых классов, невозможно (такой прием, поддерживаемый в неуправляемом языке C++, известен как множественное наследование). Попытка создать класс, для которого указаны два непосредственных родительских класса, как продемонстрировано в следующем коде, приведет к ошибке на этапе компиляции:
// Недопустимо! Множественное наследование
// классов в языке C# не разрешено!
class WontWork
: BaseClassOne, BaseClassTwo
{}
В главе 8 вы увидите, что платформа .NET Core позволяет классу или структуре реализовывать любое количество дискретных интерфейсов. Таким способом тип C# может поддерживать несколько линий поведения, одновременно избегая сложностей, которые связаны с множественным наследованием. Применяя этот подход, можно строить развитые иерархии интерфейсов, которые моделируют сложные линии поведения (см. главу 8).
Язык C# предлагает еще одно ключевое слово,
sealed
, которое предотвращает наследование. Когда класс помечен как sealed
(запечатанный), компилятор не позволяет создавать классы, производные от него. Например, пусть вы приняли решение о том, что дальнейшее расширение класса MiniVan
не имеет смысла:
// Класс Minivan не может быть расширен!
sealed class MiniVan : Car
{
}
Если вы или ваш коллега попытаетесь унаследовать от запечатанного класса
MiniVan
, то получите ошибку на этапе компиляции:
// Ошибка! Нельзя расширять класс, помеченный ключевым словом sealed!
class DeluxeMiniVan
: MiniVan
{
}
Запечатывание класса чаще всего имеет наибольший смысл при проектировании обслуживающего класса. Скажем, в пространстве имен
System
определены многочисленные запечатанные классы, такие как String
. Таким образом, как и в случае MiniVan
, если вы попытаетесь построить новый класс, который расширял бы System.String
, то получите ошибку на этапе компиляции:
// Еще одна ошибка! Нельзя расширять класс, помеченный как sealed!
class MyString
: String
{
}
На заметку! В главе 4 вы узнали о том, что структуры C# всегда неявно запечатаны (см. табл. 4.3). Следовательно, создать структуру, производную от другой структуры, класс, производный от структуры, или структуру, производную от класса, невозможно. Структуры могут применяться для моделирования только отдельных, атомарных, определяемых пользователем типов. Если вы хотите задействовать отношение "является", тогда должны использовать классы.
Нетрудно догадаться, что есть многие другие детали наследования, о которых вы узнаете в оставшемся материале главы. Пока просто примите к сведению, что операция двоеточия позволяет устанавливать отношения "базовый-производный" между классами, а ключевое слово
sealed
предотвращает последующее наследование.
В главе 2 кратко упоминалось о том, что среда Visual Studio позволяет устанавливать отношения "базовый-производный" между классами визуальным образом во время проектирования. Для работы с указанным аспектом IDE-среды сначала понадобится добавить в текущий проект новый файл диаграммы классов. Выберите пункт меню Project►Add New Item (Проект►Добавить новый элемент) и щелкните на значке Class Diagram (Диаграмма классов); на рис. 6.1 видно, что файл был переименован с
ClassDiagraml.cd
на Cars.cd
.
После щелчка на кнопке Add (Добавить) отобразится пустая поверхность проектирования. Чтобы добавить типы в визуальный конструктор классов, просто перетаскивайте на эту поверхность каждый файл из окна Solution Explorer (Проводник решений). Также вспомните, что удаление элемента из визуального конструктора (путем его выбора и нажатия клавиши <Delete>) не приводит к уничтожению ассоциированного с ним исходного кода, а просто убирает элемент из поверхности конструктора. Текущая иерархия классов показана на рис. 6.2.
Как говорилось в главе 2. помимо простого отображения отношений между типами внутри текущего приложения можно также создавать новые типы и наполнять их членами, применяя панель инструментов конструктора классов и окно Class Details (Детали класса).
При желании можете свободно использовать указанные визуальные инструменты во время проработки оставшихся глав книги. Однако всегда анализируйте сгенерированный код, чтобы четко понимать, что эти инструменты для вас сделали.
Теперь, когда вы видели базовый синтаксис наследования, давайте построим более сложный пример и рассмотрим многочисленные детали построения иерархий классов. Мы снова обратимся к классу
Employee
, который был спроектирован в главе 5. Первым делом создайте новый проект консольного приложения C# по имени Employees
.
Далее скопируйте в проект
Employees
файлы Employee.cs
, Employee.Core.cs
и EmployeePayTypeEnum.cs
, созданные ранее в проекте EmployeeApp
из главы 5.
На заметку! До выхода .NET Core, чтобы использовать файлы в проекте С#, на них необходимо было ссылаться в файле
.csproj
. В версии .NET Core все файлы из текущей структуры каталогов автоматически включаются в проект. Простого копирования нескольких файлов из другого проекта достаточно для их включения в ваш проект.
Прежде чем приступать к построению каких-то производных классов, следует уделить внимание одной детали. Поскольку первоначальный класс
Employee
был создан в проекте по имени EmployeeApp
, он находится внутри идентично названного пространства имен .NET Core. Пространства имен подробно рассматриваются в главе 16; тем не менее, ради простоты переименуйте текущее пространство имен (в обоих файлах) на Employees
, чтобы оно совпадало с именем нового проекта:
// Не забудьте изменить название пространства имен в обоих файлах С#!
namespace Employees
{
partial class Employee
{...}
}
На заметку! Если вы удалили стандартный конструктор во время внесения изменений в код класса
Employee
в главе 5, тогда снова добавьте его в класс.
Вторая деталь касается удаления любого закомментированного кода из различных итераций класса
Employee
, рассмотренных в примере главы 5.
На заметку! В качестве проверки работоспособности скомпилируйте и запустите новый проект, введя
dotnet run
в окне командной подсказки (в каталоге проекта) или нажав <Ctrl+F5> в случае использования Visual Studio. Пока что программа ничего не делает, но это позволит удостовериться в отсутствии ошибок на этапе компиляции.
Цель в том, чтобы создать семейство классов, моделирующих разнообразные типы сотрудников в компании. Предположим, что необходимо задействовать функциональность класса
Employee
при создании двух новых классов (SalesPerson
и Manager
). Новый класс SalesPerson
"является" Employee
(как и Manager
). Вспомните, что в модели классического наследования базовые классы (вроде Employee
) обычно применяются для определения характеристик, общих для всех наследников. Подклассы (такие как SalesPerson
и Manager
) расширяют общую функциональность, добавляя к ней специфическую функциональность.
В настоящем примере мы будем считать, что класс
Manager
расширяет Employee
, сохраняя количество фондовых опционов, тогда как класс SalesPerson
поддерживает хранение количества продаж. Добавьте новый файл класса (Manager.cs
), в котором определен класс Manager
со следующим автоматическим свойством:
// Менеджерам нужно знать количество их фондовых опционов.
class Manager : Employee
{
public int StockOptions { get; set; }
}
Затем добавьте еще один новый файл класса (
SalesPerson.cs
), в котором определен класс SalesPerson
с подходящим автоматическим свойством:
// Продавцам нужно знать количество продаж.
class SalesPerson : Employee
{
public int SalesNumber { get; set; }
}
После того как отношение "является" установлено, классы
SalesPerson
и Manager
автоматически наследуют все открытые члены базового класса Employee
. В целях иллюстрации обновите операторы верхнего уровня, как показано ниже:
// Создание объекта подкласса и доступ к функциональности базового класса.
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
SalesPerson fred = new SalesPerson
{
Age = 31, Name = "Fred", SalesNumber = 50
};
В текущий момент объекты классов
SalesPerson
и Manager
могут создаваться только с использованием "бесплатно полученного" стандартного конструктора (см. главу 5). Памятуя о данном факте, предположим, что в класс Manager
добавлен новый конструктор с шестью аргументами, который вызывается следующим образом:
...
// Предположим, что у Manager есть конструктор с такой сигнатурой:
// (string fullName, int age, int empId,
// float currPay, string ssn, int numbOfOpts)
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
Взглянув на список параметров, легко заметить, что большинство аргументов должно быть сохранено в переменных-членах, определенных в базовом классе
Employee
. Чтобы сделать это, в классе Manager
можно было бы реализовать показанный ниже специальный конструктор:
public Manager(string fullName, int age, int empId,
float currPay, string ssn, int numbOfOpts)
{
// Это свойство определено в классе Manager.
StockOptions = numbOfOpts;
// Присвоить входные параметры, используя
// унаследованные свойства родительского класса.
Id = empId;
Age = age;
Name = fullName;
Pay = currPay;
PayType = EmployeePayTypeEnum.Salaried;
// Если свойство SSN окажется доступным только для чтения,
// тогда здесь возникнет ошибка на этапе компиляции!
SocialSecurityNumber = ssn;
}
Первая проблема с таким подходом связана с тем, что если любое свойство определено как допускающее только чтение (например, свойство
SocialSecurityNumber
), то присвоить значение входного параметра string
данному полю не удастся, как можно видеть в финальном операторе специального конструктора.
Вторая проблема заключается в том, что был косвенно создан довольно неэффективный конструктор, учитывая тот факт, что в C# стандартный конструктор базового класса вызывается автоматически перед выполнением логики конструктора производного класса, если не указано иначе. После этого момента текущая реализация имеет доступ к многочисленным открытым свойствам базового класса
Employee
для установки его состояния. Таким образом, во время создания объекта Manager
на самом деле выполнялось восемь действий (обращения к шести унаследованным свойствам и двум конструкторам).
Для оптимизации создания объектов производного класса необходимо корректно реализовать конструкторы подкласса, чтобы они явно вызывали подходящий специальный конструктор базового класса вместо стандартного конструктора. Подобным образом можно сократить количество вызовов инициализации унаследованных членов (что уменьшит время обработки). Первым делом обеспечьте наличие в родительском классе
Employee
следующего конструктора с шестью аргументами:
// Добавление в базовый класс Employee.
public Employee(string name, int age, int id, float pay, string empSsn,
EmployeePayTypeEnum
payType)
{
Name = name;
Id = id;
Age = age;
Pay = pay;
SocialSecurityNumber = empSsn;
PayType = payType;
}
Модифицируйте специальный конструктор в классе
Manager
, чтобы вызвать конструктор Employee
с применением ключевого слова base
:
public Manager(string fullName, int age, int empId,
float currPay, string ssn, int numbOfOpts)
: base(fullName, age, empId, currPay, ssn,
EmployeePayTypeEnum.Salaried)
{
// Это свойство определено в классе Manager.
StockOptions = numbOfOpts;
}
Здесь ключевое слово
base
ссылается на сигнатуру конструктора (подобно синтаксису, используемому для объединения конструкторов одиночного класса в цепочку через ключевое слово this
, как обсуждалось в главе 5), что всегда указывает производному конструктору на необходимость передачи данных конструктору непосредственного родительского класса. В рассматриваемой ситуации явно вызывается конструктор с шестью параметрами, определенный в Employee
, что избавляет от излишних обращений во время создания объекта дочернего класса. Кроме того, в класс Manager
добавлена особая линия поведения, которая заключается в том, что тип оплаты всегда устанавливается в Salaried
. Специальный конструктор класса SalesPerson
выглядит почти идентично, но только тип оплаты устанавливается в Commission
:
// В качестве общего правила запомните, что все подклассы должны
// явно вызывать подходящий конструктор базового класса.
public SalesPerson(string fullName, int age, int empId,
float currPay, string ssn, int numbOfSales)
: base(fullName, age, empId, currPay, ssn,
EmployeePayTypeEnum.Commission)
{
// Это принадлежит нам!
SalesNumber = numbOfSales;
}
На заметку! Ключевое слово
base
можно применять всякий раз, когда подкласс желает обратиться к открытому или защищенному члену, определенному в родительском классе. Использование этого ключевого слова не ограничивается логикой конструктора. Вы увидите примеры применения ключевого слова base
в подобной манере позже в главе при рассмотрении полиморфизма.
Наконец, вспомните, что после добавления к определению класса специального конструктора стандартный конструктор молча удаляется. Следовательно, не забудьте переопределить стандартный конструктор для классов
SalesPerson
и Manager
. Вот пример:
// Аналогичным образом переопределите стандартный
// конструктор также и в классе Manager.
public SalesPerson() {}
Как вы уже знаете, открытые элементы напрямую доступны отовсюду, в то время как закрытые элементы могут быть доступны только в классе, где они определены. Вспомните из главы 5, что C# опережает многие другие современные объектные языки и предоставляет дополнительное ключевое слово для определения доступности членов —
protected
(защищенный).
Когда базовый класс определяет защищенные данные или защищенные члены, он устанавливает набор элементов, которые могут быть непосредственно доступны любому наследнику. Если вы хотите разрешить дочерним классам
SalesPerson
и Manager
напрямую обращаться к разделу данных, который определен в Employee
, то модифицируйте исходный класс Employee
(в файле EmployeeCore.cs
), как показано ниже:
// Защищенные данные состояния.
partial class Employee
{
// Производные классы теперь могут иметь прямой доступ к этой информации.
protected string EmpName;
protected int EmpId;
protected float CurrPay;
protected int EmpAge;
protected string EmpSsn;
protected EmployeePayTypeEnum EmpPayType;
...
}
На заметку! По соглашению защищенные члены именуются в стиле Pascal (
EmpName
), а не в "верблюжьем" стиле с подчеркиванием (_empName
). Это не является требованием языка, но представляет собой распространенный стиль написания кода. Если вы решите обновить имена, как было сделано здесь, тогда не забудьте переименовать все поддерживающие методы в свойствах, чтобы они соответствовали защищенным свойствам с именами в стиле Pascal.
Преимущество определения защищенных членов в базовом классе заключается в том, что производным классам больше не придется обращаться к данным косвенно, используя открытые методы и свойства. Разумеется, подходу присущ и недостаток: когда производный класс имеет прямой доступ к внутренним данным своего родителя, то есть вероятность непредумышленного обхода существующих бизнес-правил, которые реализованы внутри открытых свойств. Определяя защищенные члены, вы создаете уровень доверия между родительским классом и дочерним классом, т.к. компилятор не будет перехватывать какие-либо нарушения бизнес-правил, предусмотренных для типа.
Наконец, имейте в виду, что с точки зрения пользователя объекта защищенные данные расцениваются как закрытые (поскольку пользователь находится "снаружи" семейства). По указанной причине следующий код недопустим:
// Ошибка! Доступ к защищенным данным из клиентского кода невозможен!
Employee emp = new Employee();
emp.empName = "Fred";
На заметку! Несмотря на то что защищенные поля данных могут нарушить инкапсуляцию, определять защищенные методы вполне безопасно (и полезно). При построении иерархий классов обычно приходится определять набор методов, которые предназначены для применения только производными типами, но не внешним миром.
Вспомните, что запечатанный класс не может быть расширен другими классами. Как уже упоминалось, такой прием чаще всего используется при проектировании обслуживающих классов. Тем не менее, при построении иерархий классов вы можете обнаружить, что определенная ветвь в цепочке наследования нуждается в "отсечении", т.к. дальнейшее ее расширение не имеет смысла. В качестве примера предположим, что вы добавили в приложение еще один класс (
PtSalesPerson
), который расширяет существующий тип SalesPerson
. Текущее обновление показано на рис. 6.3.
Класс
PtSalesPerson
представляет продавца, который работает на условиях частичной занятости. В качестве варианта скажем, что нужно гарантировать отсутствие возможности создания подкласса PtSalesPerson
. Чтобы предотвратить наследование от класса, необходимо применить ключевое слово sealed
:
sealed class PtSalesPerson : SalesPerson
{
public PtSalesPerson(string fullName, int age, int empId,
float currPay, string ssn, int numbOfSales)
: base(fullName, age, empId, currPay, ssn, numbOfSales)
{
}
// Остальные члены класса...
}
Появившиеся в версии C# 9.0 типы записей также поддерживают наследование. Чтобы выяснить как, отложите пока свою работу над проектом
Employees
и создайте новый проект консольного приложения по имени RecordInheritance
. Добавьте в него два файла с именами Car.cs
и MiniVan.cs
, содержащими следующие определения записей:
// Car.cs
namespace RecordInheritance
{
//Car record type
public record Car
{
public string Make { get; init; }
public string Model { get; init; }
public string Color { get; init; }
public Car(string make, string model, string color)
{
Make = make;
Model = model;
Color = color;
}
}
}
// MiniVan.cs
namespace RecordInheritance
{
//MiniVan record type
public sealed record MiniVan : Car
{
public int Seating { get; init; }
public MiniVan(string make, string model, string color, int seating)
: base(make,
model, color)
{
Seating = seating;
}
}
}
Обратите внимание, что между примерами использования типов записей и предшествующими примерами применения классов нет большой разницы. Модификатор доступа
protected
для свойств и методов ведет себя аналогично, а модификатор доступа sealed
для типа записи запрещает другим типам записей быть производными от запечатанных типов записей. Вы также обнаружите работу с унаследованными типами записей в оставшихся разделах главы. Причина в том, что типы записей — это всего лишь особый вид неизменяемого класса (как объяснялось в главе 5). Вдобавок типы записей включают неявные приведения к своим базовым классам, что демонстрируется в коде ниже:
using System;
using RecordInheritance;
Console.WriteLine("Record type inheritance!");
Car c = new Car("Honda","Pilot","Blue");
MiniVan m = new MiniVan("Honda", "Pilot", "Blue",10);
Console.WriteLine($"Checking MiniVan is-a Car:{m is Car}");
// Проверка, является ли MiniVan типом Car
Как и можно было ожидать, проверка того, что
m
является Car
, возвращает true
, как видно в следующем выводе:
Record type inheritance!
Checking minvan is-a car:True
Важно отметить, что хотя типы записей представляют собой специализированные классы, вы не можете организовывать перекрестное наследование между классами и записями. Другими словами, классы нельзя наследовать от типов записей, а типы записей не допускается наследовать от классов. Взгляните на приведенный далее код; последние два примера не скомпилируются:
namespace RecordInheritance
{
public class TestClass { }
public record TestRecord { }
// Классы не могут быть унаследованы от записей
// public class Test2 : TestRecord { }
// Записи не могут быть унаследованы от классов
// public record Test2 : TestClass { }
}
Наследование также работает с позиционными типами записей. Создайте в своем проекте новый файл по имени
PositionalRecordTypes.cs
и поместите в него следующий код:
namespace RecordInheritance
{
public record PositionalCar (string Make, string Model, string Color);
public record PositionalMiniVan (string Make, string Model, string Color)
: PositionalCar(Make, Model, Color);
}
Добавьте к операторам верхнего уровня показанный ниже код, с помощью которого можно подтвердить то, что вам уже известно: позиционные типы записей работают точно так же, как типы записей.
PositionalCar pc = new PositionalCar("Honda", "Pilot", "Blue");
PositionalMiniVan pm = new PositionalMiniVan("Honda", "Pilot", "Blue", 10);
Console.WriteLine($"Checking PositionalMiniVan is-a PositionalCar:
{pm is PositionalCar}");
Вспомните из главы 5, что для определения эквивалентности типы записей используют семантику значений. Еще одна деталь относительно типов записей связана с тем, что тип записи является частью соображения, касающегося эквивалентности. Скажем, взгляните на следующие тривиальные примеры:
public record MotorCycle(string Make, string Model);
public record Scooter(string Make, string Model) : MotorCycle(Make,Model);
Игнорируя тот факт, что унаследованные классы обычно расширяют базовые классы, в приведенных простых примерах определяются два разных типа записей, которые имеют те же самые свойства. В случае создания экземпляров с одинаковыми значениями для свойств они не пройдут проверку на предмет эквивалентности из-за того, что принадлежат разным типам. В качестве примера рассмотрим показанный далее код и результаты его выполнения:
MotorCycle mc = new MotorCycle("Harley","Lowrider");
Scooter sc = new Scooter("Harley", "Lowrider");
Console.WriteLine($"MotorCycle and Scooter are equal: {Equals(mc,sc)}");
Вот вывод:
Record type inheritance!
MotorCycle and Scooter are equal: False
Вам уже известно, что повторное использование кода встречается в двух видах. Только что было продемонстрировано классическое отношение "является". Перед тем, как мы начнем исследование третьего принципа ООП (полиморфизма), давайте взглянем на отношение "имеет" (также известное как модель включения/делегации или агрегация). Возвратитесь к проекту
Employees
и создайте новый файл по имени BenefitPackage.cs
. Поместите в него следующий код, моделирующий пакет льгот для сотрудников:
namespace Employees
{
// Этот новый тип будет функционировать как включаемый класс.
class BenefitPackage
{
// Предположим, что есть другие члены, представляющие
// медицинские/стоматологические программы и т.п.
public double ComputePayDeduction()
{
return 125.0;
}
}
}
Очевидно, что было бы довольно странно устанавливать отношение "является" между классом
BenefitPackage
и типами сотрудников. (Разве сотрудник "является" пакетом льгот? Вряд ли.) Однако должно быть ясно, что какое-то отношение между ними должно быть установлено. Короче говоря, нужно выразить идею о том, что каждый сотрудник "имеет" пакет льгот. Для этого можно модифицировать определение класса Employee
следующим образом:
// Теперь сотрудники имеют льготы.
partial class Employee
{
// Contain a BenefitPackage object.
protected BenefitPackage EmpBenefits = new BenefitPackage();
...
}
На данной стадии вы имеете объект, который благополучно содержит в себе другой объект. Тем не менее, открытие доступа к функциональности содержащегося объекта внешнему миру требует делегации. Делегация — просто действие по добавлению во включающий класс открытых членов, которые работают с функциональностью содержащегося внутри объекта.
Например, вы могли бы изменить класс
Employee
так, чтобы он открывал доступ к включенному объекту EmpBenefits
с применением специального свойства, а также использовать его функциональность внутренне посредством нового метода по имени GetBenefitCost()
:
partial class Employee
{
// Содержит объект BenefitPackage.
protected BenefitPackage EmpBenefits = new BenefitPackage();
// Открывает доступ к некоторому поведению, связанному со льготами.
public double GetBenefitCost()
=> EmpBenefits.ComputePayDeduction();
// Открывает доступ к объекту через специальное свойство.
public BenefitPackage Benefits
{
get { return EmpBenefits; }
set { EmpBenefits = value; }
}
}
В показанном ниже обновленном коде верхнего уровня обратите внимание на взаимодействие с внутренним типом
BenefitsPackage
, который определен в типе Employee
:
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
...
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
double cost = chucky.GetBenefitCost();
Console.WriteLine($"Benefit Cost: {cost}");
Console.ReadLine();
В главе 5 кратко упоминалась концепция вложенных типов, которая является развитием рассмотренного выше отношения "имеет". В C# (а также в других языках .NET) допускается определять тип (перечисление, класс, интерфейс, структуру или делегат) прямо внутри области действия класса либо структуры. В таком случае вложенный (или "внутренний") тип считается членом охватывающего (или "внешнего") типа, и в глазах исполняющей системы им можно манипулировать как любым другим членом (полем, свойством, методом и событием). Синтаксис, применяемый для вложения типа, достаточно прост:
public class OuterClass
{
// Открытый вложенный тип может использоваться кем угодно.
public class PublicInnerClass {}
// Закрытый вложенный тип может использоваться
.
// только членами включающего класса
private class PrivateInnerClass {}
}
Хотя синтаксис довольно ясен, ситуации, в которых это может понадобиться, не настолько очевидны. Для того чтобы понять данный прием, рассмотрим характерные черты вложенных типов.
• Вложенные типы позволяют получить полный контроль над уровнем доступа внутреннего типа, потому что они могут быть объявлены как закрытые (вспомните, что невложенные классы нельзя объявлять с ключевым словом
private
).
• Поскольку вложенный тип является членом включающего класса, он может иметь доступ к закрытым членам этого включающего класса.
• Часто вложенный тип полезен только как вспомогательный для внешнего класса и не предназначен для использования во внешнем мире.
Когда тип включает в себя другой тип класса, он может создавать переменные-члены этого типа, как в случае любого другого элемента данных. Однако если с вложенным типом нужно работать за пределами включающего типа, тогда его придется уточнять именем включающего типа. Взгляните на приведенный ниже код:
// Создать и использовать объект открытого вложенного класса. Нормально!
OuterClass.PublicInnerClass inner;
inner = new OuterClass.PublicInnerClass();
// Ошибка на этапе компиляции! Доступ к закрытому вложенному
// классу невозможен!
OuterClass.PrivateInnerClass inner2;
inner2 = new OuterClass.PrivateInnerClass();
Для применения такой концепции в примере с сотрудниками предположим, что определение
BenefitPackage
теперь вложено непосредственно в класс Employee
:
partial class Employee
{
public class BenefitPackage
{
// Предположим, что есть другие члены, представляющие
// медицинские/стоматологические программы и т.д.
public double ComputePayDeduction()
{
return 125.0;
}
}
...
}
Процесс вложения может распространяться настолько "глубоко", насколько требуется. Например, пусть необходимо создать перечисление по имени
BenefitPackageLevel
, документирующее разнообразные уровни льгот, которые может выбирать сотрудник. Чтобы программно обеспечить тесную связь между типами Employee
, BenefitPackage
и BenefitPackageLevel
, перечисление можно вложить следующим образом:
// В класс Employee вложен класс BenefitPackage.
public partial class Employee
{
// В класс BenefitPackage вложено перечисление BenefitPackageLevel.
public class BenefitPackage
{
public enum BenefitPackageLevel
{
Standard, Gold, Platinum
}
public double ComputePayDeduction()
{
return 125.0;
}
}
...
}
Вот как приходится использовать перечисление
BenefitPackageLevel
из-за отношений вложения:
...
// Определить уровень льгот.
Employee.BenefitPackage.BenefitPackageLevel myBenefitLevel =
Employee.BenefitPackage.BenefitPackageLevel.Platinum;
Итак, к настоящему моменту вы ознакомились с несколькими ключевыми словами (и концепциями), которые позволяют строить иерархии типов, связанных посредством классического наследования, включения и вложения. Не беспокойтесь, если пока еще не все детали ясны. На протяжении оставшихся глав книги будет построено немало иерархий. А теперь давайте перейдем к исследованию последнего принципа ООП — полиморфизма.
Вспомните, что в базовом классе
Employee
определен метод по имени GiveBonus()
, который первоначально был реализован так (до его обновления с целью использования шаблона свойств):
public partial class Employee
{
public void GiveBonus(float amount) => _currPay += amount;
...
}
Поскольку метод
GiveBonus()
был определен с ключевым словом public
, бонусы можно раздавать продавцам и менеджерам (а также продавцам с частичной занятостью):
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
// Выдать каждому сотруднику бонус?
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
chucky.GiveBonus(300);
chucky.DisplayStats();
Console.WriteLine();
SalesPerson fran = new SalesPerson("Fran", 43, 93, 3000, "932-32-3232", 31);
fran.GiveBonus(200);
fran.DisplayStats();
Console.ReadLine();
Проблема с текущим проектным решением заключается в том, что открыто унаследованный метод
GiveBonus()
функционирует идентично для всех подклассов. В идеале при подсчете бонуса для штатного продавца и частично занятого продавца должно приниматься во внимание количество продаж. Возможно, менеджеры вместе с денежным вознаграждением должны получать дополнительные фондовые опционы. Учитывая это, вы однажды столкнетесь с интересным вопросом: "Как сделать так, чтобы связанные типы реагировали по-разному на один и тот же запрос?". Попробуем найти на него ответ.
Полиморфизм предоставляет подклассу способ определения собственной версии метода, определенного в его базовом классе, с применением процесса, который называется переопределением метода. Чтобы модернизировать текущее проектное решение, необходимо понимать смысл ключевых слов
virtual
и override
. Если базовый класс желает определить метод, который может быть (но не обязательно) переопределен в подклассе, то он должен пометить его ключевым словом virtual
:
partial class Employee
{
// Теперь этот метод может быть переопределен в производном классе.
public virtual void GiveBonus(float amount)
{
Pay += amount;
}
...
}
На заметку! Методы, помеченные ключевым словом
virtual
, называются виртуальными методами.
Когда подкласс желает изменить реализацию деталей виртуального метода, он прибегает к помощи ключевого слова
override
. Например, классы SalesPerson
и Manager
могли бы переопределять метод GiveBonus()
, как показано ниже (предположим, что класс PtSalesPerson
не будет переопределять GiveBonus()
, а потому просто наследует его версию из SalesPerson
):
using System;
class SalesPerson : Employee
{
...
// Бонус продавца зависит от количества продаж.
public override void GiveBonus(float amount)
{
int salesBonus = 0;
if (SalesNumber >= 0 && SalesNumber <= 100)
salesBonus = 10;
else
{
if (SalesNumber >= 101 && SalesNumber <= 200)
salesBonus = 15;
else
salesBonus = 20;
}
base.GiveBonus(amount * salesBonus);
}
}
class Manager : Employee
{
...
public override void GiveBonus(float amount)
{
base.GiveBonus(amount);
Random r = new Random();
StockOptions += r.Next(500);
}
}
Обратите внимание, что каждый переопределенный метод может задействовать стандартное поведение посредством ключевого слова
base
.
Таким образом, полностью повторять реализацию логики метода
GiveBonus()
вовсе не обязательно, а взамен можно повторно использовать (и расширять) стандартное поведение родительского класса.
Также предположим, что текущий метод
DisplayStats()
класса Employee
объявлен виртуальным:
public virtual void DisplayStats()
{
Console.WriteLine("Name: {0}", Name);
Console.WriteLine("Id: {0}", Id);
Console.WriteLine("Age: {0}", Age);
Console.WriteLine("Pay: {0}", Pay);
Console.WriteLine("SSN: {0}", SocialSecurityNumber);
}
Тогда каждый подкласс может переопределять метод
DisplayStats()
с целью отображения количества продаж (для продавцов) и текущих фондовых опционов (для менеджеров). Например, рассмотрим версию метода DisplayStats()
из класса Manager
(класс SalesPerson
реализовывал бы метод DisplayStats()
в похожей манере, выводя на консоль количество продаж):
// Manager.cs
public override void DisplayStats()
{
base.DisplayStats();
// Вывод количества фондовых опционов
Console.WriteLine("Number of Stock Options: {0}", StockOptions);
}
// SalesPerson.cs
public override void DisplayStats()
{
base.DisplayStats();
// Вывод количества продаж
Console.WriteLine("Number of Sales: {0}", SalesNumber);
}
Теперь, когда каждый подкласс может истолковывать эти виртуальные методы значащим для него образом, их экземпляры ведут себя как более независимые сущности:
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
// Лучшая система бонусов!
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
chucky.GiveBonus(300);
chucky.DisplayStats();
Console.WriteLine();
SalesPerson fran = new SalesPerson("Fran", 43, 93, 3000, "932-32-3232", 31);
fran.GiveBonus(200);
fran.DisplayStats();
Console.ReadLine();
Вот результат тестового запуска приложения в его текущем виде:
***** The Employee Class Hierarchy *****
Name: Chucky
ID: 92
Age: 50
Pay: 100300
SSN: 333-23-2322
Number of Stock Options: 9337
Name: Fran
ID: 93
Age: 43
Pay: 5000
SSN: 932-32-3232
Number of Sales: 31
Вы наверняка заметили, что при переопределении члена класса приходится вспоминать тип каждого параметра, не говоря уже об имени метода и соглашениях по передаче параметров (
ref
, out
и params
). В Visual Studio и Visual Studio Code доступно полезное средство IntelliSense
, к которому можно обращаться при переопределении виртуального члена. Если вы наберете слово override
внутри области действия типа класса (и затем нажмете клавишу пробела), то IntelliSense
автоматически отобразит список всех допускающих переопределение членов родительского класса, исключая уже переопределенные методы.
Если вы выберете член и нажмете клавишу <Enter>, то IDE-среда отреагирует автоматическим заполнением заглушки метода. Обратите внимание, что вы также получаете оператор кода, который вызывает родительскую версию виртуального члена (можете удалить эту строку, если она не нужна). Например, при использовании описанного приема для переопределения метода
DisplayStats()
вы обнаружите следующий автоматически сгенерированный код:
public override void DisplayStats()
{
base.DisplayStats();
}
Вспомните, что к типу класса можно применить ключевое слово
sealed
, чтобы предотвратить расширение его поведения другими типами через наследование. Ранее класс PtSalesPerson
был запечатан на основе предположения о том, что разработчикам не имеет смысла дальше расширять эту линию наследования.
Следует отметить, что временами желательно не запечатывать класс целиком, а просто предотвратить переопределение некоторых виртуальных методов в производных типах. В качестве примера предположим, что вы не хотите, чтобы продавцы с частичной занятостью получали специальные бонусы. Предотвратить переопределение виртуального метода
GiveBonus()
в классе PtSalesPerson
можно, запечатав данный метод в классе SalesPerson
:
// Класс SalesPerson запечатал метод GiveBonus()!
class SalesPerson : Employee
{
...
public override sealed void GiveBonus(float amount)
{
...
}
}
Здесь класс
SalesPerson
на самом деле переопределяет виртуальный метод GiveBonus()
, определенный в Employee
, но явно помечает его как sealed
. Таким образом, попытка переопределения метода GiveBonus()
в классе PtSalesPerson
приведет к ошибке на этапе компиляции:
sealed class PTSalesPerson : SalesPerson
{
...
// Ошибка на этапе компиляции! Переопределять этот метод
// в классе PtSalesPerson нельзя, т.к. он был запечатан.
{
}
}
В настоящий момент базовый класс
Employee
спроектирован так, что поставляет различные данные-члены своим наследникам, а также предлагает два виртуальных метода (GiveBonus()
и DisplayStats()
), которые могут быть переопределены в наследниках. Хотя все это замечательно, у такого проектного решения имеется один весьма странный побочный эффект: создавать экземпляры базового класса Employee
можно напрямую:
// Что это будет означать?
Employee X = new Employee();
В нашем примере базовый класс
Employee
служит единственной цели — определять общие члены для всех подклассов. По всем признакам мы не намерены позволять кому-либо создавать непосредственные экземпляры типа Employee
, т.к. он концептуально чересчур общий. Например, если кто-то заявит, что он сотрудник, то тут же возникнет вопрос: сотрудник какого рода (консультант, инструктор, административный работник, литературный редактор, советник в правительстве)?
Учитывая, что многие базовые классы имеют тенденцию быть довольно расплывчатыми сущностями, намного более эффективным проектным решением для данного примера будет предотвращение возможности непосредственного создания в коде нового объекта
Employee
. В C# цели можно добиться за счет использования ключевого слова abstract
в определении класса, создавая в итоге абстрактный базовый класс:
// Превращение класса Employee в абстрактный для
// предотвращения прямого создания его экземпляров.
abstract partial class Employee
{
...
}
Теперь попытка создания экземпляра класса
Employee
приводит к ошибке на этапе компиляции:
// Ошибка! Нельзя создавать экземпляр абстрактного класса!
Employee X = new Employee();
Определение класса, экземпляры которого нельзя создавать напрямую, на первый взгляд может показаться странным. Однако вспомните, что базовые классы (абстрактные или нет) полезны тем, что содержат все общие данные и функциональность для производных типов. Такая форма абстракции дает возможность считать, что "идея" сотрудника является полностью допустимой, просто это не конкретная сущность. Кроме того, необходимо понимать, что хотя непосредственно создавать экземпляры абстрактного класса невозможно, они все равно появляются в памяти при создании экземпляров производных классов. Таким образом, для абстрактных классов вполне нормально (и общепринято) определять любое количество конструкторов, которые вызываются косвенно, когда выделяется память под экземпляры производных классов.
На данной стадии у нас есть довольно интересная иерархия сотрудников. Мы добавим чуть больше функциональности к приложению позже, при рассмотрении правил приведения типов С#. А пока на рис. 6.4 представлено текущее проектное решение.
Когда класс определен как абстрактный базовый (посредством ключевого слова
abstract
), в нем может определяться любое число абстрактных членов. Абстрактные члены могут применяться везде, где требуется определить член, который не предоставляет стандартной реализации, но должен приниматься во внимание каждым производным классом. Тем самым вы навязываете полиморфный интерфейс каждому наследнику, оставляя им задачу реализации конкретных деталей абстрактных методов.
Выражаясь упрощенно, полиморфный интерфейс абстрактного базового класса просто ссылается на его набор виртуальных и абстрактных методов. На самом деле это намного интереснее, чем может показаться на первый взгляд, поскольку данная характерная черта ООП позволяет строить легко расширяемые и гибкие приложения. В целях иллюстрации мы реализуем (и слегка модифицируем) иерархию фигур, кратко описанную в главе 5 во время обзора основных принципов ООП. Для начала создадим новый проект консольного приложения C# по имени
Shapes
.
На рис. 6.5 обратите внимание на то, что типы
Hexagon
и Circle
расширяют базовый класс Shape
. Как и любой базовый класс. Shape
определяет набор членов (в данном случае свойство PetName
и метод Draw()
), общих для всех наследников.
Во многом подобно иерархии классов для сотрудников вы должны иметь возможность запретить создание экземпляров класса
Shape
напрямую, потому что он представляет слишком абстрактную концепцию. Чтобы предотвратить непосредственное создание экземпляров класса Shape
, его можно определить как абстрактный класс. К тому же с учетом того, что производные типы должны уникальным образом реагировать на вызов метода Draw()
, пометьте его как virtual
и определите стандартную реализацию. Важно отметить, что конструктор помечен как protected
, поэтому его можно вызывать только в производных классах.
// Абстрактный базовый класс иерархии.
abstract class Shape
{
protected Shape(string name = "NoName")
{ PetName = name; }
public string PetName { get; set; }
// Единственный виртуальный метод.
public virtual void Draw()
{
Console.WriteLine("Inside Shape.Draw()");
}
}
Обратите внимание, что виртуальный метод
Draw()
предоставляет стандартную реализацию, которая просто выводит на консоль сообщение, информирующее о факте вызова метода Draw()
из базового класса Shape
. Теперь вспомните, что когда метод помечен ключевым словом virtual
, он поддерживает стандартную реализацию, которую автоматически наследуют все производные типы. Если дочерний класс так решит, то он может переопределить такой метод, но он не обязан это делать. Рассмотрим показанную ниже реализацию типов Circle
и Hexagon
:
// В классе Circle метод Draw() НЕ переопределяется.
class Circle : Shape
{
public Circle() {}
public Circle(string name) : base(name){}
}
// В классе Hexagon метод Draw() переопределяется.
class Hexagon : Shape
{
public Hexagon() {}
public Hexagon(string name) : base(name){}
public override void Draw()
{
Console.WriteLine("Drawing {0} the Hexagon", PetName);
}
}
Полезность абстрактных методов становится совершенно ясной, как только вы снова вспомните, что подклассы никогда не обязаны переопределять виртуальные методы (как в случае
Circle
). Следовательно, если создать экземпляры типов Hexagon
и Circle
, то обнаружится, что Hexagon
знает, как правильно "рисовать" себя (или, по крайней мере, выводить на консоль подходящее сообщение). Тем не менее, реакция Circle
порядком сбивает с толку.
Console.WriteLine("***** Fun with Polymorphism *****\n");
Hexagon hex = new Hexagon("Beth");
hex.Draw();
Circle cir = new Circle("Cindy");
// Вызывает реализацию базового класса!
cir.Draw();
Console.ReadLine();
Взгляните на вывод предыдущего кода:
***** Fun with Polymorphism *****
Drawing Beth the Hexagon
Inside Shape.Draw()
Очевидно, что это не самое разумное проектное решение для текущей иерархии. Чтобы вынудить каждый дочерний класс переопределять метод
Draw()
, его можно определить как абстрактный метод класса Shape
, т.е. какая-либо стандартная реализация вообще не предлагается. Для пометки метода как абстрактного в C# используется ключевое слово abstract
. Обратите внимание, что абстрактные методы не предоставляют никакой реализации:
abstract class Shape
{
// Вынудить все дочерние классы определять способ своей визуализации.
public abstract void Draw();
...
}
На заметку! Абстрактные методы могут быть определены только в абстрактных классах, иначе возникнет ошибка на этапе компиляции.
Методы, помеченные как
abstrac
t, являются чистым протоколом. Они просто определяют имя, возвращаемый тип (если есть) и набор параметров (при необходимости). Здесь абстрактный класс Shape
информирует производные типы о том, что у него есть метод по имени Draw()
, который не принимает аргументов и ничего не возвращает. О необходимых деталях должен позаботиться производный класс.
С учетом сказанного метод
Draw()
в классе Circle
теперь должен быть обязательно переопределен. В противном случае Circle
также должен быть абстрактным классом и декорироваться ключевым словом abstract
(что очевидно не подходит в настоящем примере). Вот изменения в коде:
// Если не реализовать здесь абстрактный метод Draw(), то Circle
// также должен считаться абстрактным и быть помечен как abstract!
class Circle : Shape
{
public Circle() {}
public Circle(string name) : base(name) {}
public override void Draw()
{
Console.WriteLine("Drawing {0} the Circle", PetName);
}
}
Итак, теперь можно предполагать, что любой класс, производный от
Shape
, действительно имеет уникальную версию метода Draw()
. Для демонстрации полной картины полиморфизма рассмотрим следующий код:
Console.WriteLine("***** Fun with Polymorphism *****\n");
// Создать массив совместимых с Shape объектов.
Shape[] myShapes = {new Hexagon(), new Circle(), new Hexagon("Mick"),
new Circle("Beth"), new Hexagon("Linda")};
// Пройти в цикле по всем элементам и взаимодействовать
// с полиморфным интерфейсом.
foreach (Shape s in myShapes)
{
s.Draw();
}
Console.ReadLine();
Ниже показан вывод, выдаваемый этим кодом:
***** Fun with Polymorphism *****
Drawing NoName the Hexagon
Drawing NoName the Circle
Drawing Mick the Hexagon
Drawing Beth the Circle
Drawing Linda the Hexagon
Данный код иллюстрирует полиморфизм в чистом виде. Хотя напрямую создавать экземпляры абстрактного базового класса (
Shape
) невозможно, с помощью абстрактной базовой переменной допускается хранить ссылки на объекты любого подкласса. Таким образом, созданный массив объектов Shape
способен хранить объекты классов, производных от базового класса Shape
(попытка добавления в массив объектов, несовместимых с Shape
, приведет к ошибке на этапе компиляции).
С учетом того, что все элементы в массиве
myShapes
на самом деле являются производными от Shape
, вы знаете, что все они поддерживают один и тот же "полиморфный интерфейс" (или, говоря проще, все они имеют метод Draw()
). Во время итерации по массиву ссылок Shape
исполняющая система самостоятельно определяет лежащий в основе тип элемента. В этот момент и вызывается корректная версия метода Draw()
.
Такой прием также делает простым безопасное расширение текущей иерархии. Например, пусть вы унаследовали от абстрактного базового класса
Shape
дополнительные классы (Triangle
, Square
и т.д.). Благодаря полиморфному интерфейсу код внутри цикла foreach
не потребует никаких изменений, т.к. компилятор обеспечивает помещение внутрь массива myShapes
только совместимых с Shape
типов.
Язык C# предоставляет возможность, которая логически противоположна переопределению методов и называется сокрытием. Выражаясь формально, если производный класс определяет член, который идентичен члену, определенному в базовом классе, то производный класс скрывает версию члена из родительского класса. В реальном мире такая ситуация чаще всего возникает, когда вы создаете подкласс от класса, который разрабатывали не вы (или ваша команда); например, такой класс может входить в состав программного пакета, приобретенного у стороннего поставщика.
В целях иллюстрации предположим, что вы получили от коллеги на доработку класс по имени
ThreeDCircle
, в котором определен метод Draw()
, не принимающий аргументов:
class ThreeDCircle
{
public void Draw()
{
Console.WriteLine("Drawing a 3D Circle");
}
}
Вы полагаете, что
ThreeDCircle
"является" Circle
, поэтому решаете унаследовать его от своего существующего типа Circle
:
class ThreeDCircle : Circle
{
public void Draw()
{
Console.WriteLine("Drawing a 3D Circle");
}
}
После перекомпиляции вы обнаружите следующее предупреждение:
'ThreeDCircle.Draw()' hides inherited member 'Circle.Draw()'. To make
the current member
override that implementation, add the override keyword.
Otherwise add the new keyword.
скрывает унаследованный член 'Shapes.ThreeDCircle.Draw()
.Shapes.Circle.Draw()
Чтобы текущий член переопределял эту реализацию, добавьте ключевое слово
.override
В противном случае добавьте ключевое слово
.'new
Дело в том, что у вас есть производный класс (
ThreeDCircle
), который содержит метод, идентичный унаследованному методу. Решить проблему можно несколькими способами. Вы могли бы просто модифицировать версию метода Draw()
в дочернем классе, добавив ключевое слово override
(как предлагает компилятор). При таком подходе у типа ThreeDCircle
появляется возможность расширять стандартное поведение родительского типа, как и требовалось. Однако если у вас нет доступа к файлу кода с определением базового класса (частый случай, когда приходится работать с множеством библиотек от сторонних поставщиков), тогда нет и возможности изменить метод Draw()
, превратив его в виртуальный член.
В качестве альтернативы вы можете добавить ключевое слово
new
к определению проблемного члена Draw()
своего производного типа (ThreeDCircle
). Поступая так, вы явно утверждаете, что реализация производного типа намеренно спроектирована для фактического игнорирования версии члена из родительского типа (в реальности это может оказаться полезным, если внешнее программное обеспечение каким-то образом конфликтует с вашим программным обеспечением).
// Этот класс расширяет Circle и скрывает унаследованный метод Draw().
class ThreeDCircle : Circle
{
// Скрыть любую реализацию Draw(), находящуюся выше в иерархии.
public new void Draw()
{
Console.WriteLine("Drawing a 3D Circle");
}
}
Вы можете также применить ключевое слово
new
к любому члену типа, который унаследован от базового класса (полю, константе, статическому члену или свойству). Продолжая пример, предположим, что в классе ThreeDCircle
необходимо скрыть унаследованное свойство PetName
:
class ThreeDCircle : Circle
{
// Скрыть свойство PetName, определенное выше в иерархии.
public new string PetName { get; set; }
// Скрыть любую реализацию Draw(), находящуюся выше в иерархии.
public new void Draw()
{
Console.WriteLine("Drawing a 3D Circle");
}
}
Наконец, имейте в виду, что вы по-прежнему можете обратиться к реализации скрытого члена из базового класса, используя явное приведение, как описано в следующем разделе. Вот пример:
...
// Здесь вызывается метод Draw(), определенный в классе ThreeDCircle.
ThreeDCircle o = new ThreeDCircle();
o.Draw();
// Здесь вызывается метод Draw(), определенный в родительском классе!
((Circle)o).Draw();
Console.ReadLine();
Теперь, когда вы умеете строить семейства взаимосвязанных типов классов, нужно изучить правила, которым подчиняются операции приведения классов. Давайте возвратимся к иерархии классов для сотрудников, созданной ранее в главе, и добавим несколько новых методов в класс
Program
(если вы прорабатываете примеры, тогда откройте проект Employees
в Visual Studio). Как описано в последнем разделе настоящей главы, изначальным базовым классом в системе является System.Object
. По указанной причине любой класс "является" Object
и может трактоваться как таковой. Таким образом, внутри переменной типа object
допускается хранить экземпляр любого типа:
static void CastingExamples()
{
// Manager "является" System.Object, поэтому в переменной
// типа object можно сохранять ссылку на Manager.
object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
}
В проекте
Employees
классы Manager
, SalesPerson
и PtSalesPerson
расширяют класс Employee
, а потому допустимая ссылка на базовый класс может хранить любой из объектов указанных классов. Следовательно, приведенный далее код также законен:
static void CastingExamples()
{
// Manager "является" System.Object, поэтому в переменной
// типа object можно сохранять ссылку на Manager.
object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
// Manager тоже "является" Employee.
Employee moonUnit = new Manager("MoonUnit Zappa", 2, 3001, 20000,
"101-11-1321", 1);
// PtSalesPerson "является" SalesPerson.
SalesPerson jill = new PtSalesPerson("Jill", 834, 3002, 100000,
"111-12-1119", 90);
}
Первое правило приведения между типами классов гласит, что когда два класса связаны отношением "является", то всегда можно безопасно сохранить объект производного типа в ссылке базового класса. Формально это называется неявным приведением, поскольку оно "просто работает" в соответствии с законами наследования. В результате появляется возможность строить некоторые мощные программные конструкции. Например, предположим, что в текущем классе
Program
определен новый метод:
static void GivePromotion(Employee emp)
{
// Повысить зарплату...
// Предоставить место на парковке компании...
Console.WriteLine("{0} was promoted!", emp.Name);
}
Из-за того, что данный метод принимает единственный параметр типа
Employee
, в сущности, ему можно передавать объект любого унаследованного от Employee
класса, учитывая наличие отношения "является":
static void CastingExamples()
{
// Manager "является" System.Object, поэтому в переменной
// типа object можно сохранять ссылку на Manager.
object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
// Manager также "является" Employee.
Employee moonUnit = new Manager("MoonUnit Zappa", 2, 3001, 20000,
"101-11-1321", 1);
GivePromotion(moonUnit);
// PtSalesPerson "является" SalesPerson.
SalesPerson jill = new PtSalesPerson("Jill", 834, 3002, 100000,
"111-12-1119", 90);
GivePromotion(jill);
}
Предыдущий код компилируется благодаря неявному приведению от типа базового класса (
Employee
) к производному классу. Но что, если вы хотите также вызвать метод GivePromotion()
с объектом frank
(хранящимся в общей ссылке System.Object
)? Если вы передадите объект frank
методу GivePromotion()
напрямую, то получите ошибку на этапе компиляции:
object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
// Ошибка!
GivePromotion(frank);
Проблема в том, что вы пытаетесь передать переменную, которая объявлена как принадлежащая не к типу
Employee
, а к более общему типу System.Object
. Учитывая, что в цепочке наследования он находится выше, чем Employee
, компилятор не разрешит неявное приведение, стараясь сохранить ваш код насколько возможно безопасным в отношении типов.
Несмотря на то что сами вы можете выяснить, что ссылка
object
указывает в памяти на объект совместимого с Employee
класса, компилятор сделать подобное не в состоянии, поскольку это не будет известно вплоть до времени выполнения. Чтобы удовлетворить компилятор, понадобится применить явное приведение, которое и является вторым правилом: в таких случаях вы можете явно приводить "вниз", используя операцию приведения С#. Базовый шаблон, которому нужно следовать при выполнении явного приведения, выглядит так:
(класс_к_которому_нужно_привести) существующая_ссылка
Таким образом, чтобы передать переменную типа
object
методу GivePromotion()
, потребуется написать следующий код:
// Правильно!
GivePromotion((Manager)frank);
Имейте в виду, что явное приведение оценивается во время выполнения, а не на этапе компиляции. Ради иллюстрации предположим, что проект
Employees
содержит копию класса Hexagon
, созданного ранее в главе. Для простоты вы можете добавить в текущий проект такой класс:
class Hexagon
{
public void Draw()
{
Console.WriteLine("Drawing a hexagon!");
}
}
Хотя приведение объекта сотрудника к объекту фигуры абсолютно лишено смысла, код вроде показанного ниже скомпилируется без ошибок:
// Привести объект frank к типу Hexagon невозможно,
// но этот код нормально скомпилируется!
object frank = new Manager();
Hexagon hex = (Hexagon)frank;
Тем не менее, вы получите ошибку времени выполнения, или более формально — исключение времени выполнения. В главе 7 будут рассматриваться подробности структурированной обработки исключений, а пока полезно отметить, что при явном приведении можно перехватывать возможные ошибки с применением ключевых слов
try
и catch
:
// Перехват возможной ошибки приведения.
object frank = new Manager();
Hexagon hex;
try
{
hex = (Hexagon)frank;
}
catch (InvalidCastException ex)
{
Console.WriteLine(ex.Message);
}
Очевидно, что показанный пример надуман; в такой ситуации вас никогда не будет беспокоить приведение между указанными типами. Однако предположим, что есть массив элементов
System.Object
, среди которых лишь малая толика содержит объекты, совместимые с Employee
. В этом случае первым делом желательно определить, совместим ли элемент массива с типом Employee
, и если да, то лишь тогда выполнить приведение.
Для быстрого определения совместимости одного типа с другим во время выполнения в C# предусмотрено ключевое слово
as
. С помощью ключевого слова as
можно определить совместимость, проверив возвращаемое значение на предмет null
. Взгляните на следующий код:
// Использование ключевого слова as для проверки совместимости.
object[] things = new object[4];
things[0] = new Hexagon();
things[1] = false;
things[2] = new Manager();
things[3] = "Last thing";
foreach (object item in things)
{
Hexagon h = item as Hexagon;
if (h == null)
{
Console.WriteLine("Item is not a hexagon"); // item - не Hexagon
}
else
{
h.Draw();
}
}
Здесь производится проход в цикле по всем элементам в массиве объектов и проверка каждого из них на совместимость с классом
Hexagon
. Метод Draw()
вызывается, если (и только если) обнаруживается объект, совместимый с Hexagon
. В противном случае выводится сообщение о том, что элемент несовместим.
В дополнение к ключевому слову
as
язык C# предлагает ключевое слово is
, предназначенное для определения совместимости типов двух элементов. Тем не менее, в отличие от ключевого слова as
, если типы не совместимы, тогда ключевое слово is
возвращает false
, а не ссылку null
. В текущий момент метод GivePromotion()
спроектирован для приема любого возможного типа, производного от Employee
. Взгляните на следующую его модификацию, в которой теперь осуществляется проверка, какой конкретно "тип сотрудника" был передан:
static void GivePromotion(Employee emp)
{
Console.WriteLine("{0} was promoted!", emp.Name);
if (emp is SalesPerson)
{
Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
((SalesPerson)emp).SalesNumber);
Console.WriteLine();
}
else if (emp is Manager)
{
Console.WriteLine("{0} had {1} stock options...", emp.Name,
((Manager)emp).StockOptions);
Console.WriteLine();
}
}
Здесь во время выполнения производится проверка с целью выяснения, на что именно в памяти указывает входная ссылка типа базового класса. После определения, принят ли объект типа
SalesPerson
или Manager
, можно применить явное приведение, чтобы получить доступ к специализированным членам данного типа. Также обратите внимание, что помещать операции приведения внутрь конструкции try/catch
не обязательно, т.к. внутри раздела if
, выполнившего проверку условия, уже известно, что приведение безопасно.
Начиная с версии C# 7.0, с помощью ключевого слова
is
переменной можно также присваивать объект преобразованного типа, если приведение работает. Это позволяет сделать предыдущий метод более ясным, устраняя проблему "двойного приведения". В предшествующем примере первое приведение выполняется, когда производится проверка совпадения типов, и если она проходит успешно, то переменную придется приводить снова. Взгляните на следующее обновление предыдущего метода:
static void GivePromotion(Employee emp)
{
Console.WriteLine("{0} was promoted!", emp.Name);
// Если SalesPerson, тогда присвоить переменной s
if (emp is SalesPerson s)
{
Console.WriteLine("{0} made {1} sale(s)!", s.Name,
s.SalesNumber);
Console.WriteLine();
}
// Если Manager, тогда присвоить переменной m
else if (emp is Manager m)
{
Console.WriteLine("{0} had {1} stock options...",
m.Name, m.StockOptions);
Console.WriteLine();
}
}
В версии C# 9.0 появились дополнительные возможности сопоставления с образцом (они были раскрыты в главе 3). Такое обновленное сопоставление с образцом можно использовать с ключевым словом
is
. Например, для проверки, что объект сотрудника не относится ни к классу Manager
, ни к классу SalesPerson
, применяйте следующий код:
if (emp is not Manager and not SalesPerson)
{
Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
Console.WriteLine();
}
Ключевое слово
is
также разрешено применять в сочетании с заполнителем для отбрасывания переменных. Вот как можно обеспечить перехват объектов всех типов в операторе if
или switch
:
if (obj is var _)
{
// Делать что-то
}
Такое условие будет давать совпадение с чем угодно, а потому следует уделять внимание порядку, в котором используется блок сравнения с отбрасыванием. Ниже показан обновленный метод
GivePromotion()
:
if (emp is SalesPerson s)
{
Console.WriteLine("{0} made {1} sale(s)!", s.Name, s.SalesNumber);
Console.WriteLine();
}
// Если Manager, тогда присвоить переменной m
else if (emp is Manager m)
{
Console.WriteLine("{0} had {1} stock options...", m.Name, m.StockOptions);
Console.WriteLine();
}
else if (emp is var _)
{
// Некорректный тип сотрудника
Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
Console.WriteLine();
}
Финальный оператор
if
будет перехватывать любой экземпляр Employee
, не являющийся Manager
, SalesPerson
или PtSalesPerson
. Не забывайте, что вы можете приводить вниз к базовому классу, поэтому PtSalesPerson
будет регистрироваться как SalesPerson
.
В главе 3 было представлено средство сопоставления с образцом C# 7.0 наряду с его обновлениями в версии C# 9.0. Теперь, когда вы обрели прочное понимание приведения, наступило время для более удачного примера. Предыдущий пример можно модернизировать для применения оператора
switch
, сопоставляющего с образцом:
static void GivePromotion(Employee emp)
{
Console.WriteLine("{0} was promoted!", emp.Name);
switch (emp)
{
case SalesPerson s:
Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
s.SalesNumber);
break;
case Manager m:
Console.WriteLine("{0} had {1} stock options...",
emp.Name, m.StockOptions);
break;
}
Console.WriteLine();
}
Когда к оператору
case
добавляется конструкция when
, для использования доступно полное определение объекта как он приводится. Например, свойство SalesNumber
существует только в классе SalesPerson
, но не в классе Employee
. Если приведение в первом операторе case
проходит успешно, то переменная s
будет содержать экземпляр класса SalesPerson
, так что оператор case
можно было бы переписать следующим образом:
case SalesPerson s when s.SalesNumber > 5:
Такие новые добавления к
is
и switch
обеспечивают удобные улучшения, которые помогают сократить объем кода, выполняющего сопоставление, как демонстрировалось в предшествующих примерах.
Отбрасывание также может применяться в операторах
switch
:
switch (emp)
{
case SalesPerson s when s.SalesNumber > 5:
Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
s.SalesNumber);
break;
case Manager m:
Console.WriteLine("{0} had {1} stock options...",
emp.Name, m.StockOptions);
break;
case Employee _:
// Некорректный тип сотрудника
Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
break;
}
Каждый входной тип уже является
Employee
и потому финальный оператор case
всегда дает true
. Однако, как было показано при представлении сопоставления с образцом в главе 3, после сопоставления оператор switch
завершает работу Это демонстрирует важность правильности порядка. Если финальный оператор case
переместить в начало, тогда никто из сотрудников не получит повышения.
В заключение мы займемся исследованием главного родительского класса
Object
. При чтении предыдущих разделов вы могли заметить, что базовые классы во всех иерархиях (Car
, Shape
, Employee
) никогда явно не указывали свои родительские классы:
// Какой класс является родительским для Car?
class Car
{...}
В мире .NET Core каждый тип в конечном итоге является производным от базового класса по имени
System.Object
, который в языке C# может быть представлен с помощью ключевого слова object
(с буквой о
в нижнем регистре). Класс Object
определяет набор общих членов для каждого типа внутри платформы. По сути, когда вы строите класс, в котором явно не указан родительский класс, компилятор автоматически делает его производным от Object
. Если вы хотите прояснить свои намерения, то можете определять классы, производные от Object
, следующим образом (однако вы не обязаны поступать так):
// Явное наследование класса от System.Object.
class Car : object
{...}
Подобно любому классу в
System.Object
определен набор членов. В показанном ниже формальном определении C# обратите внимание, что некоторые члены объявлены как virtual
, указывая на возможность их переопределения в подклассах, тогда как другие помечены ключевым словом static
(и потому вызываются на уровне класса):
public class Object
{
// Виртуальные члены.
public virtual bool Equals(object obj);
protected virtual void Finalize();
public virtual int GetHashCode();
public virtual string ToString();
// Невиртуальные члены уровня экземпляра.
public Type GetType();
protected object MemberwiseClone();
// Статические члены.
public static bool Equals(object objA, object objB);
public static bool ReferenceEquals(object objA, object objB);
}
В табл. 6.1 приведен обзор функциональности, предоставляемой некоторыми часто используемыми методами
System.Object
.
Чтобы проиллюстрировать стандартное поведение, обеспечиваемое базовым классом
Object
, создайте новый проект консольного приложения C# по имени ObjectOverrides
.
Добавьте в проект новый файл класса С#, содержащий следующее пустое определение типа
Person
:
// Не забывайте, что класс Person расширяет Object.
class Person {}
Теперь обновите операторы верхнего уровня для взаимодействия с унаследованными членами
System.Object
:
Console.WriteLine("***** Fun with System.Object *****\n");
Person p1 = new Person();
// Использовать унаследованные члены System.Object.
Console.WriteLine("ToString: {0}", p1.ToString());
Console.WriteLine("Hash code: {0}", p1.GetHashCode());
Console.WriteLine("Type: {0}", p1.GetType());
// Создать другие ссылки на pi.
Person p2 = p1;
object o = p2;
// Указывают ли ссылки на один и тот же объект в памяти?
if (o.Equals(p1) && p2.Equals(o))
{
Console.WriteLine("Same instance!");
}
Console.ReadLine();
}
Вот вывод, получаемый в результате выполнения этого кода:
***** Fun with System.Object *****
ToString: ObjectOverrides.Person
Hash code: 58225482
Type: ObjectOverrides.Person
Same instance!
Обратите внимание на то, что стандартная реализация
ToString()
возвращает полностью заданное имя текущего типа (ObjectOverrides.Person
). Как будет показано в главе 15, где исследуется построение специальных пространств имен, каждый проект C# определяет "корневое пространство имен", название которого совпадает с именем проекта. Здесь мы создали проект по имени ObjectOverrides
, поэтому тип Person
и класс Program
помещены внутрь пространства имен ObjectOverrides
.
Стандартное поведение метода
Equals()
заключается в проверке, указывают ли две переменные на один и тот же объект в памяти. В коде мы создаем новую переменную Person
по имени pi
. В этот момент новый объект Person
помещается в управляемую кучу. Переменная р2
также относится к типу Person
. Тем не менее, вместо создания нового экземпляра переменной р2
присваивается ссылка pi
. Таким образом, переменные pi
и р2
указывают на один и тот же объект в памяти, как и переменная о
(типа object
). Учитывая, что pi
, р2
и о
указывают на одно и то же местоположение в памяти, проверка эквивалентности дает положительный результат.
Хотя готовое поведение
System.Object
в ряде случаев может удовлетворять всем потребностям, довольно часто в специальных типах часть этих унаследованных методов переопределяется. В целях иллюстрации модифицируем класс Person
, добавив свойства, которые представляют имя, фамилию и возраст лица; все они могут быть установлены с помощью специального конструктора:
// Не забывайте, что класс Person расширяет Object.
class Person
{
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public int Age { get; set; }
public Person(string fName, string lName, int personAge)
{
FirstName = fName;
LastName = lName;
Age = personAge;
}
public Person(){}
}
Многие создаваемые классы (и структуры) могут извлечь преимущества от переопределения метода
ToString()
для возвращения строки с текстовым представлением текущего состояния экземпляра типа. Помимо прочего это полезно при отладке. То, как вы решите конструировать результирующую строку — дело личных предпочтений; однако рекомендуемый подход предусматривает отделение пар "имя-значение" друг от друга двоеточиями и помещение всей строки в квадратные скобки (такому принципу следуют многие типы из библиотек базовых классов .NET Core). Взгляните на следующую переопределенную версию ToString()
для класса Person
:
public override string ToString()
=> $"[First Name: {FirstName}; Last Name: {LastName};
Age:
{Age}]";
Приведенная реализация метода
ToString()
довольно прямолинейна, потому что класс Person
содержит всего три порции данных состояния. Тем не менее, всегда помните о том, что правильное переопределение ToString()
должно также учитывать любые данные, определенные выше в цепочке наследования.
При переопределении метода
ToString()
для класса, расширяющего специальный базовый класс, первым делом необходимо получить возвращаемое значение ToString()
из родительского класса, используя ключевое слово base
. После получения строковых данных родительского класса их можно дополнить специальной информацией производного класса.
Давайте также переопределим поведение метода
Object.Equals()
, чтобы работать с семантикой на основе значений. Вспомните, что по умолчанию Equals()
возвращает true
, только если два сравниваемых объекта ссылаются на один и тот же экземпляр объекта в памяти. Для класса Person
может оказаться полезной такая реализация Equals()
, которая возвращает true
, если две сравниваемые переменные содержат те же самые значения состояния (например, фамилию, имя и возраст).
Прежде всего, обратите внимание, что входной аргумент метода
Equals()
имеет общий тип System.Object
. В связи с этим первым делом необходимо удостовериться в том, что вызывающий код действительно передал экземпляр типа Person
, и для дополнительной подстраховки проверить, что входной параметр не является ссылкой null
.
После того, как вы установите, что вызывающий код передал выделенный экземпляр
Person
, один из подходов предусматривает реализацию метода Equals()
для сравнения поле за полем данных входного объекта с данными текущего объекта:
public override bool Equals(object obj)
{
if (!(obj is Person temp))
{
return false;
}
if (temp.FirstName == this.FirstName
&& temp.LastName == this.LastName
&& temp.Age == this.Age)
{
return true;
}
return false;
}
Здесь производится сравнение значений входного объекта с внутренними значениями текущего объекта (обратите внимание на применение ключевого слова
this
). Если имя, фамилия и возраст в двух объектах идентичны, то эти два объекта имеют одинаковые данные состояния и возвращается значение true
. Любые другие результаты приводят к возвращению false
.
Хотя такой подход действительно работает, вы определенно в состоянии представить, насколько трудоемкой была бы реализация специального метода
Equals()
для нетривиальных типов, которые могут содержать десятки полей данных. Распространенное сокращение предусматривает использование собственной реализации метода ToString()
. Если класс располагает подходящей реализацией ToString()
, в которой учитываются все поля данных вверх по цепочке наследования, тогда можно просто сравнивать строковые данные объектов (проверив на равенство null
):
// Больше нет необходимости приводить obj к типу Person,
// т.к. у всех типов имеется метод ToString().
public override bool Equals(object obj)
=> obj?.ToString() == ToString();
Обратите внимание, что в этом случае нет необходимости проверять входной аргумент на принадлежность к корректному типу (
Person
в нашем примере), поскольку метод ToString()
поддерживают все типы .NET. Еще лучше то, что больше не требуется выполнять проверку на предмет равенства свойство за свойством, т.к. теперь просто проверяются значения, возвращаемые методом ToString()
.
В случае переопределения в классе метода
Equals()
вы также должны переопределить стандартную реализацию метода GetHashCode()
. Выражаясь упрощенно, хеш-код — это числовое значение, которое представляет объект как специфическое состояние. Например, если вы создадите две переменные типа string
, хранящие значение Hello
, то они должны давать один и тот же хеш-код. Однако если одна из них хранит строку в нижнем регистре (hello
), то должны получаться разные хеш-коды.
Для выдачи хеш-значения метод
System.Object.GetHashCode()
по умолчанию применяет адрес текущей ячейки памяти, где расположен объект. Тем не менее, если вы строите специальный тип, подлежащий хранению в экземпляре типа Hashtable
(из пространства имен System.Collections
), тогда всегда должны переопределять данный член, потому что для извлечения объекта тип Hashtable
будет вызывать методы Equals()
и GetHashCode()
.
На заметку! Говоря точнее, класс
System.Collections.Hashtable
внутренне вызывает метод GetHashCode()
, чтобы получить общее представление о местоположении объекта, а с помощью последующего (внутреннего) вызова метода Equals()
определяет его точно.
Хотя в настоящем примере мы не собираемся помещать объекты
Person
внутрь System.Collections.Hashtable
, ради полноты изложения давайте переопределим метод GetHashCode()
. Существует много алгоритмов, которые можно применять для создания хеш-кода, как весьма изощренных, так и не очень. В большинстве ситуаций есть возможность генерировать значение хеш-кода, полагаясь на реализацию метода GetHashCode()
из класса System.String
.
Учитывая, что класс
String
уже имеет эффективный алгоритм хеширования, использующий для вычисления хеш-значения символьные данные объекта String
, вы можете просто вызвать метод GetHashCode()
с той частью полей данных, которая должна быть уникальной во всех экземплярах (вроде номера карточки социального страхования), если ее удается идентифицировать. Таким образом, если в классе Person
определено свойство SSN
, то вы могли бы написать следующий код:
// Предположим, что имеется свойство SSN.
class Person
{
public string SSN {get; } = "";
public Person(string fName, string lName, int personAge,
string ssn)
{
FirstName = fName;
LastName = lName;
Age = personAge;
SSN = ssn;
}
// Возвратить хеш-код на основе уникальных строковых данных.
public override int GetHashCode() => SSN.GetHashCode();
}
В случае использования в качестве основы хеш-кода свойства, допускающего чтение и запись, вы получите предупреждение. После того, как объект создан, хеш-код должен быть неизменяемым. В предыдущем примере свойство
SSN
имеет только метод get
, что делает его допускающим только чтение, и устанавливать его можно только в конструкторе.
Если вы не можете отыскать единый фрагмент уникальных строковых данных, но есть переопределенный метод
ToString()
, который удовлетворяет соглашению о доступе только по чтению, тогда вызывайте GetHashCode()
на собственном строковом представлении:
// Возвратить хеш-код на основе значения, возвращаемого
// методом ToString() для объекта Person.
public override int GetHashCode() => ToString().GetHashCode();
Теперь, когда виртуальные члены класса
Object
переопределены, обновите операторы верхнего уровня, чтобы протестировать внесенные изменения:
Console.WriteLine("***** Fun with System.Object *****\n");
// ПРИМЕЧАНИЕ: мы хотим, чтобы эти объекты были идентичными
// в целях тестирования методов Equals() и GetHashCode().
Person p1 = new Person("Homer", "Simpson", 50, "111-11-1111");
Person p2 = new Person("Homer", "Simpson", 50, "111-11-1111");
// Получить строковые версии объектов.
Console.WriteLine("p1.ToString() = {0}", p1.ToString());
Console.WriteLine("p2.ToString() = {0}", p2.ToString());
// Протестировать переопределенный метод Equals().
Console.WriteLine("p1 = p2?: {0}", p1.Equals(p2));
// Протестировать хеш-коды.
// По-прежнему используется хеш-значение SSN
Console.WriteLine("Same hash codes?: {0}", p1.GetHashCode() == p2.GetHashCode());
Console.WriteLine();
// Изменить значение Age объекта p2 и протестировать снова.
p2.Age = 45;
Console.WriteLine("p1.ToString() = {0}", p1.ToString());
Console.WriteLine("p2.ToString() = {0}", p2.ToString());
Console.WriteLine("p1 = p2?: {0}", p1.Equals(p2));
// По-прежнему используется хеш-значение SSN
Console.WriteLine("Same hash codes?: {0}", p1.GetHashCode() == p2.GetHashCode());
Console.ReadLine();
Ниже показан вывод:
***** Fun with System.Object *****
p1.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p2.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p1 = p2?: True
Same hash codes?: True
p1.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p2.ToString() = [First Name: Homer; Last Name: Simpson; Age: 45]
p1 = p2?: False
Same hash codes?: True
В дополнение к только что рассмотренным членам уровня экземпляра класс
System.Object
определяет два статических члена, которые также проверяют эквивалентность на основе значений или на основе ссылок. Взгляните на следующий код:
static void StaticMembersOfObject()
{
// Статические члены System.Object.
Person p3 = new Person("Sally", "Jones", 4);
Person p4 = new Person("Sally", "Jones", 4);
Console.WriteLine("P3 and P4 have same state: {0}",
object.Equals(p3, p4));
// Р3 и P4 имеют то же самое состояние
Console.WriteLine("P3 and P4 are pointing to same object: {0}",
object.ReferenceEquals(p3, p4));
// Р3 и P4 указывают на тот же самый объект
}
Здесь вы имеете возможность просто отправить два объекта (любого типа) и позволить классу
System.Object
выяснить детали автоматически. Ниже показан вывод, полученный в результате вызова метода StaticMembersOfObject()
в операторах верхнего уровня:
***** Fun with System.Object *****
P3 and P4 have the same state: True
P3 and P4 are pointing to the same object: False
В настоящей главе объяснялась роль и детали наследования и полиморфизма. В ней были представлены многочисленные новые ключевые слова и лексемы для поддержки каждого приема. Например, вспомните, что с помощью двоеточия указывается родительский класс для создаваемого типа. Родительские типы способны определять любое количество виртуальных и/или абстрактных членов для установления полиморфного интерфейса. Производные типы переопределяют эти члены с применением ключевого слова
override
.
Вдобавок к построению множества иерархий классов в главе также исследовалось явное приведение между базовыми и производными типами. В завершение главы рассматривались особенности главного родительского класса в библиотеках базовых классов .NET Core —
System.Object
.
В настоящей главе вы узнаете о том, как иметь дело с аномалиями, возникающими во время выполнения кода С#, с использованием структурированной обработки исключений. Будут описаны не только ключевые слова С#, предназначенные для этих целей (
try
, catch
, throw
, finally
, when
), но и разница между исключениями уровня приложения и уровня системы, а также роль базового класса System.Exception
. Кроме того, будет показано, как создавать специальные исключения, и рассмотрены некоторые инструменты отладки в Visual Studio, связанные с исключениями.
Что бы ни нашептывало наше (порой завышенное) самомнение, идеальных программистов не существует. Разработка программного обеспечения является сложным делом, и из-за такой сложности довольно часто даже самые лучшие программы поставляются с разнообразными проблемами. В одних случаях проблема возникает из-за "плохо написанного" кода (например, по причине выхода за границы массива), а в других — из-за ввода пользователем некорректных данных, которые не были учтены в кодовой базе приложения (скажем, когда в поле для телефонного номера вводится значение вроде
Chucky
). Вне зависимости от причин проблемы в конечном итоге приложение не работает ожидаемым образом. Чтобы подготовить почву для предстоящего обсуждения структурированной обработки исключений, рассмотрим три распространенных термина, которые применяются для описания аномалий.
• Дефекты. Выражаясь просто, это ошибки, которые допустил программист. В качестве примера предположим, что вы программируете на неуправляемом C++. Если вы забудете освободить динамически выделенную память, что приводит к утечке памяти, тогда получите дефект.
• Пользовательские ошибки. С другой стороны, пользовательские ошибки обычно возникают из-за тех, кто запускает приложение, а не тех, кто его создает. Например, ввод конечным пользователем в текстовом поле неправильно сформированной строки с высокой вероятностью может привести к генерации ошибки, если в коде не была предусмотрена обработка некорректного ввода.
• Исключения. Исключениями обычно считаются аномалии во время выполнения, которые трудно (а то и невозможно) учесть на стадии программирования приложения. Примерами исключений могут быть попытка подключения к базе данных, которая больше не существует, открытие запорченного XML-файла или попытка установления связи с машиной, которая в текущий момент находится в автономном режиме. В каждом из упомянутых случаев программист (или конечный пользователь) обладает довольно низким контролем над такими "исключительными" обстоятельствами.
С учетом приведенных определений должно быть понятно, что структурированная обработка исключений в .NET — прием работы с исключительными ситуациями во время выполнения. Тем не менее, даже для дефектов и пользовательских ошибок, которые ускользнули от глаз программиста, исполняющая среда будет часто генерировать соответствующее исключение, идентифицирующее возникшую проблему. Скажем, в библиотеках базовых классов .NET 5 определены многочисленные исключения, такие как
FormatException
, IndexOutOfRangeException
, FileNotFoundException
, ArgumentOutOfRangeException
и т.д.
В рамках терминологии .NET исключение объясняется дефектами, некорректным пользовательским вводом и ошибками времени выполнения, даже если программисты могут трактовать каждую аномалию как отдельную проблему. Однако прежде чем погружаться в детали, формализуем роль структурированной обработки исключений и посмотрим, чем она отличается от традиционных приемов обработки ошибок.
На заметку! Чтобы сделать примеры кода максимально ясными, мы не будем перехватывать абсолютно все исключения, которые может выдавать заданный метод из библиотеки базовых классов. Разумеется, в своих проектах производственного уровня вы должны широко использовать приемы, описанные в главе.
До появления платформы .NET обработка ошибок в среде операционной системы Windows представляла собой запутанную смесь технологий. Многие программисты внедряли собственную логику обработки ошибок в контекст разрабатываемого приложения. Например, команда разработчиков могла определять набор числовых констант для представления известных условий возникновения ошибок и затем применять эти константы как возвращаемые значения методов. Взгляните на следующий фрагмент кода на языке С:
/* Типичный механизм перехвата ошибок в стиле С. */
#define E_FILENOTFOUND 1000
int UseFileSystem()
{
// Предполагается, что в этой функции происходит нечто
// такое, что приводит к возврату следующего значения.
return E_FILENOTFOUND;
}
void main()
{
int retVal = UseFileSystem();
if(retVal == E_FILENOTFOUND)
printf("Cannot find file..."); // H e удалось найти файл
}
Такой подход далек от идеала, учитывая тот факт, что константа
E_FILENOTFOUND
— всего лишь числовое значение, которое немногое говорит о том, каким образом решить возникшую проблему. В идеале желательно, чтобы название ошибки, описательное сообщение и другая полезная информация об условиях возникновения ошибки были помещены в единственный четко определенный пакет (что как раз и происходит при структурированной обработке исключений). В дополнение к специальным приемам, к которым прибегают разработчики, внутри API-интерфейса Windows определены сотни кодов ошибок, которые поступают в виде определений #define
и HRESULT
, а также очень многих вариаций простых булевских значений (bool
, BOOL
, VARIANT_BOOL
и т.д.).
Очевидной проблемой, присущей таким старым приемам, является полное отсутствие симметрии. Каждый подход более или менее подгоняется под заданную технологию, заданный язык и возможно даже заданный проект. Чтобы положить конец такому безумству, платформа .NET предложила стандартную методику для генерации и перехвата ошибок времени выполнения — структурированную обработку исключений. Достоинство этой методики в том, что разработчики теперь имеют унифицированный подход к обработке ошибок, который является общим для всех языков, ориентированных на .NET. Следовательно, способ обработки ошибок, используемый программистом на С#, синтаксически подобен способу, который применяет программист на VB или программист на C++, имеющий дело с C++/CLI.
Дополнительное преимущество связано с тем, что синтаксис, используемый для генерации и отлавливания исключений за пределами границ сборок и машины, идентичен. Скажем, если вы применяете язык C# при построении REST-службы ASP.NET Core, то можете сгенерировать исключение JSON для удаленного вызывающего кода, используя те же самые ключевые слова, которые позволяют генерировать исключения внутри методов одного приложения.
Еще одно преимущество исключений .NET состоит в том, что в отличие от загадочных числовых значений они представляют собой объекты, в которых содержится читабельное описание проблемы, а также детальный снимок стека вызовов на момент первоначального возникновения исключения. Более того, конечному пользователю можно предоставить справочную ссылку, которая указывает на URL-адрес с подробностями об ошибке, а также специальные данные, определенные программистом.
Программирование со структурированной обработкой исключений предусматривает применение четырех взаимосвязанных сущностей:
• тип класса, который представляет детали исключения;
• член, способный генерировать экземпляр класса исключения в вызывающем коде при соответствующих обстоятельствах;
• блок кода на вызывающей стороне, который обращается к члену, предрасположенному к возникновению исключения;
• блок кода на вызывающей стороне, который будет обрабатывать (или перехватывать) исключение, если оно возникнет.
Язык программирования C# предлагает пять ключевых слов (
try
, catch
, throw
, finally
и when
), которые позволяют генерировать и обрабатывать исключения. Объект, представляющий текущую проблему, относится к классу, который расширяет класс System.Exception
(или производный от него класс). С учетом сказанного давайте исследуем роль данного базового класса, касающегося исключений.
Все исключения в конечном итоге происходят от базового класса
System.Exception
, который в свою очередь является производным от System.Object
. Ниже показана основная часть этого класса (обратите внимание, что некоторые его члены являются виртуальными и, следовательно, могут быть переопределены в производных классах):
public class Exception : ISerializable
{
// Открытые конструкторы
public Exception(string message, Exception innerException);
public Exception(string message);
public Exception();
...
// Методы
public virtual Exception GetBaseException();
public virtual void GetObjectData(SerializationInfo info,
StreamingContext context);
// Свойства
public virtual IDictionary Data { get; }
public virtual string HelpLink { get; set; }
public int HResult {get;set;}
public Exception InnerException { get; }
public virtual string Message { get; }
public virtual string Source { get; set; }
public virtual string StackTrace { get; }
public MethodBase TargetSite { get; }
}
Как видите, многие свойства, определенные в классе
System.Exception
, по своей природе допускают только чтение. Причина в том, что стандартные значения для каждого из них обычно будут предоставляться производными типами. Например, стандартное сообщение типа IndexOutOfRangeException
выглядит так: "Index was outside the bounds of the array" (Индекс вышел за границы массива).
В табл. 7.1 описаны наиболее важные члены класса
System.Exception
.
Чтобы продемонстрировать полезность структурированной обработки исключений, мы должны создать класс, который будет генерировать исключение в надлежащих (или, можно сказать, исключительных) обстоятельствах. Создадим новый проект консольного приложения C# по имени
SimpleException
и определим в нем два класса (Car
(автомобиль) и Radio
(радиоприемник)), связав их между собой отношением "имеет". В классе Radio
определен единственный метод, который отвечает за включение и выключение радиоприемника:
using System;
namespace SimpleException
{
class Radio
{
public void TurnOn(bool on)
{
Console.WriteLine(on ? "Jamming..." : "Quiet time...");
}
}
}
В дополнение к использованию класса
Radio
через включение/делегацию класс Car
(его код показан ниже) определен так, что если пользователь превышает предопределенную максимальную скорость (заданную с помощью константного члена MaxSpeed
), тогда двигатель выходит из строя, приводя объект Car
в нерабочее состояние (отражается закрытой переменной-членом типа bool
по имени _carIsDead
).
Кроме того, класс
Car
имеет несколько свойств для представления текущей скорости и указанного пользователем "дружественного названия" автомобиля, а также различные конструкторы для установки состояния нового объекта Car
. Ниже приведено полное определение Car
вместе с поясняющими комментариями.
using System;
namespace SimpleException
{
class Car
{
// Константа для представления максимальной скорости.
public const int MaxSpeed = 100;
// Свойства автомобиля.
public int CurrentSpeed {get; set;} = 0;
public string PetName {get; set;} = "";
// He вышел ли автомобиль из строя?
private bool _carIsDead;
// В автомобиле имеется радиоприемник.
private readonly Radio _theMusicBox = new Radio();
// Конструкторы.
public Car() {}
public Car(string name, int speed)
{
CurrentSpeed = speed;
PetName = name;
}
public void CrankTunes(bool state)
{
// Делегировать запрос внутреннему объекту.
_theMusicBox.TurnOn(state);
}
// Проверить, не перегрелся ли автомобиль.
public void Accelerate(int delta)
{
if (_carIsDead)
{
Console.WriteLine("{0} is out of order...", PetName);
}
else
{
CurrentSpeed += delta;
if (CurrentSpeed > MaxSpeed)
{
Console.WriteLine("{0} has overheated!", PetName);
CurrentSpeed = 0;
_carIsDead = true;
}
else
{
Console.WriteLine("=> CurrentSpeed = {0}",
CurrentSpeed);
}
}
}
}
}
Обновите код в файле
Program.cs
, чтобы заставить объект Car
превышать заранее заданную максимальную скорость (установленную в 100
внутри класса Car
):
using System;
using System.Collections;
using SimpleException;
Console.WriteLine("***** Simple Exception Example *****");
Console.WriteLine("=> Creating a car and stepping on it!");
Car myCar = new Car("Zippy", 20);
myCar.CrankTunes(true);
for (int i = 0; i < 10; i++)
{
myCar.Accelerate(10);
}
Console.ReadLine();
В результате запуска кода будет получен следующий вывод:
***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100
Zippy has overheated!
Zippy is out of order...
Теперь, имея функциональный класс
Car
, давайте рассмотрим простейший способ генерации исключения. Текущая реализация метода Accelerate()
просто отображает сообщение об ошибке, если вызывающий код пытается разогнать автомобиль до скорости, превышающей верхний предел.
Чтобы модернизировать метод
Accelerate()
для генерации исключения, когда пользователь пытается разогнать автомобиль до скорости, которая превышает установленный предел, потребуется создать и сконфигурировать новый экземпляр класса System.Exception
, установив значение доступного только для чтения свойства Message
через конструктор класса. Для отправки объекта ошибки обратно вызывающему коду применяется ключевое слово throw
языка С#. Ниже приведен обновленный код метода Accelerate()
:
// На этот раз генерировать исключение, если пользователь
// превышает предел, указанный в MaxSpeed.
public void Accelerate(int delta)
{
if (_carIsDead)
{
Console.WriteLine("{0} is out of order...", PetName);
}
else
{
CurrentSpeed += delta;
if (CurrentSpeed >= MaxSpeed)
{
CurrentSpeed = 0;
_carIsDead = true;
// Использовать ключевое слово throw для генерации исключения.
throw new Exception($"{PetName} has overheated!");
}
Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
}
}
Прежде чем выяснять, каким образом вызывающий код будет перехватывать данное исключение, необходимо отметить несколько интересных моментов. Для начала, если вы генерируете исключение, то всегда самостоятельно решаете, как вводится в действие ошибка и когда должно генерироваться исключение. Здесь мы предполагаем, что при попытке увеличить скорость объекта
Car
за пределы максимума должен быть сгенерирован объект System.Exception
для уведомления о невозможности продолжить выполнение метода Accelerate()
(в зависимости от создаваемого приложения такое предположение может быть как допустимым, так и нет).
В качестве альтернативы метод
Accelerate()
можно было бы реализовать так, чтобы он производил автоматическое восстановление, не генерируя перед этим исключение. По большому счету исключения должны генерироваться только при возникновении более критичного условия (например, отсутствие нужного файла, невозможность подключения к базе данных и т.п.) и не использоваться как механизм потока логики. Принятие решения о том, что должно служить причиной генерации исключения, требует серьезного обдумывания и поиска веских оснований на стадии проектирования. Для преследуемых сейчас целей будем считать, что попытка увеличить скорость автомобиля выше максимально допустимой является вполне оправданной причиной для выдачи исключения.
Кроме того, обратите внимание, что из кода метода был удален финальный оператор
else
. Когда исключение генерируется (либо инфраструктурой, либо вручную с применением оператора throw
), управление возвращается вызывающему методу (или блоку catch
в операторе try
). Это устраняет необходимость в финальном else
. Оставите вы его ради лучшей читабельности или нет, зависит от ваших стандартов написания кода.
В любом случае, если вы снова запустите приложение с показанной ранее логикой в операторах верхнего уровня, то исключение в итоге будет сгенерировано. В показанном далее выводе видно, что результат отсутствия обработки этой ошибки нельзя назвать идеальным, учитывая получение многословного сообщения об ошибке (с вашим путем к файлу и номерами строк) и последующее прекращение работы программы:
***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100
Unhandled exception. System.Exception: Zippy has overheated!
at SimpleException.Car.Accelerate(Int32 delta)
in [путь к файлу]\Car.cs:line 52
at SimpleException.Program.Main(String[] args)
in [путь к файлу]\Program.cs:line 16
На заметку! Те, кто пришел в .NET 5 из мира Java, должны помнить о том, что члены типа не прототипируются набором исключений, которые они могут генерировать (другими словами, платформа .NET Core не поддерживает проверяемые исключения). Лучше это или хуже, но вы не обязаны обрабатывать каждое исключение, генерируемое отдельно взятым членом.
Поскольку метод
Accelerate()
теперь генерирует исключение, вызывающий код должен быть готов обработать его, если оно возникнет. При вызове метода, который может сгенерировать исключение, должен использоваться блок try/catch
. После перехвата объекта исключения можно обращаться к различным его членам и извлекать детальную информацию о проблеме.
Дальнейшие действия с такими данными в значительной степени зависят от вас. Вы можете зафиксировать их в файле отчета, записать в журнал событий, отправить по электронной почте системному администратору или отобразить конечному пользователю сообщение о проблеме. Здесь мы просто выводим детали исключения в окно консоли:
// Обработка сгенерированного исключения.
Console.WriteLine("***** Simple Exception Example *****");
Console.WriteLine("=> Creating a car and stepping on it!");
Car myCar = new Car("Zippy", 20);
myCar.CrankTunes(true);
// Разогнаться до скорости, превышающей максимальный
// предел автомобиля, с целью выдачи исключения
try
{
for(int i = 0; i < 10; i++)
{
myCar. Accelerate(10);
}
}
catch(Exception e)
{
Console.WriteLine("\n*** Error! ***"); // ошибка
Console.WriteLine("Method: {0}", e.TargetSite); // метод
Console.WriteLine("Message: {0}", e.Message); // сообщение
Console.WriteLine("Source: {0}", e.Source); // источник
}
// Ошибка была обработана, выполнение продолжается со следующего оператора.
Console.WriteLine("\n***** Out of exception logic *****");
Console.ReadLine();
По существу блок
try
представляет собой раздел операторов, которые в ходе выполнения могут генерировать исключение. Если исключение обнаруживается, тогда управление переходит к соответствующему блоку catch
. С другой стороны, если код внутри блока try
исключение не сгенерировал, то блок catch
полностью пропускается, и выполнение проходит обычным образом. Ниже представлен вывод, полученный в результате тестового запуска данной программы:
***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100
*** Error! ***
Method: Void Accelerate(Int32)
Message: Zippy has overheated!
Source: SimpleException
***** Out of exception logic *****
Как видите, после обработки исключения приложение может продолжать свое функционирование с оператора, находящегося после блока
catch
. В некоторых обстоятельствах исключение может оказаться достаточно критическим для того, чтобы служить основанием завершения работы приложения. Тем не менее, во многих случаях логика внутри обработчика исключений позволяет приложению спокойно продолжить выполнение (хотя, может быть, с несколько меньшим объемом функциональности, например, без возможности взаимодействия с удаленным источником данных).
До выхода версии C# 7 ключевое слово
throw
было оператором, что означало возможность генерации исключения только там, где разрешены операторы. В C# 7.0 и последующих версиях ключевое слово throw
доступно также в виде выражения и может использоваться везде, где разрешены выражения.
В настоящий момент объект
System.Exception
, сконфигурированный внутри метода Accelerate()
, просто устанавливает значение, доступное через свойство Message
(посредством параметра конструктора). Как было показано ранее в табл. 7.1, класс Exception
также предлагает несколько дополнительных членов (TargetSite
, StackTrace
, HelpLink
и Data
), которые полезны для дальнейшего уточнения природы возникшей проблемы. Чтобы усовершенствовать текущий пример, давайте по очереди рассмотрим возможности упомянутых членов.
Свойство
System.Exception.TargetSite
позволяет выяснить разнообразные детали о методе, который сгенерировал заданное исключение. Как демонстрировалось в предыдущем примере кода, в результате вывода значения свойства TargetSite
отобразится возвращаемое значение, имя и типы параметров метода, который сгенерировал исключение. Однако свойство TargetSite
возвращает не простую строку, а строго типизированный объект System.Reflection.MethodBase
. Данный тип можно применять для сбора многочисленных деталей, касающихся проблемного метода, а также класса, в котором метод определен. В целях иллюстрации измените предыдущую логику в блоке catch
следующим образом:
// Свойство TargetSite в действительности возвращает объект MethodBase.
catch(Exception e)
{
Console.WriteLine("\n*** Error! ***");
Console.WriteLine("Member name: {0}", e.TargetSite); // имя члена
Console.WriteLine("Class defining member: {0}",
e.TargetSite.DeclaringType); // класс, определяющий член
Console.WriteLine("Member type: {0}",
e.TargetSite.MemberType);
Console.WriteLine("Message: {0}", e.Message); // сообщение
Console.WriteLine("Source: {0}", e.Source); // источник
}
Console.WriteLine("\n***** Out of exception logic *****");
Console.ReadLine();
На этот раз в коде используется свойство
MethodBase.DeclaringType
для выяснения полностью заданного имени класса, сгенерировавшего ошибку (в данном случае SimpleException.Car
), а также свойство MemberType
объекта MethodBase
для идентификации вида члена (например, член является свойством или методом), в котором возникло исключение. Ниже показано, как будет выглядеть вывод в результате выполнения логики в блоке catch
:
*** Error! ***
Member name: Void Accelerate(Int32)
Class defining member: SimpleException.Car
Member type: Method
Message: Zippy has overheated!
Source: SimpleException
Свойство
System.Exception.StackTrace
позволяет идентифицировать последовательность вызовов, которая в результате привела к генерации исключения. Значение данного свойства никогда не устанавливается вручную — это делается автоматически во время создания объекта исключения. Чтобы удостовериться в сказанном, модифицируйте логику в блоке catch
:
catch(Exception e)
{
...
Console.WriteLine("Stack: {0}", e.StackTrace);
}
Снова запустив программу, в окне консоли можно обнаружить следующие данные трассировки стека (естественно, номера строк и пути к файлам у вас могут отличаться):
Stack: at SimpleException.Car.Accelerate(Int32 delta)
in [путь к файлу]\car.cs:line 57 at $.$(String[] args)
in [путь к файлу]\Program.cs:line 20
Значение типа
string
, возвращаемое свойством StackTrace
, отражает последовательность вызовов, которая привела к генерации данного исключения. Обратите внимание, что самый нижний номер строки в string
указывает на место возникновения первого вызова в последовательности, а самый верхний — на место, где точно находится проблемный член. Очевидно, что такая информация очень полезна во время отладки или при ведении журнала для конкретного приложения, т.к. дает возможность отследить путь к источнику ошибки.
Хотя свойства
TargetSite
и StackTrace
позволяют программистам выяснить, почему возникло конкретное исключение, информация подобного рода не особенно полезна для пользователей. Как уже было показано, с помощью свойства System.Exception
. Message можно извлечь читабельную информацию и отобразить ее конечному пользователю. Вдобавок можно установить свойство HelpLink
для указания на специальный URL или стандартный справочный файл, где приводятся более подробные сведения о проблеме.
По умолчанию значением свойства
HelpLink
является пустая строка. Обновите исключение с использованием инициализации объектов, чтобы предоставить более интересное значение. Ниже показан модифицированный код метода Car.Accelerate()
:
public void Accelerate(int delta)
{
if (_carIsDead)
{
Console.WriteLine("{0} is out of order...", PetName);
}
else
{
CurrentSpeed += delta;
if (CurrentSpeed >= MaxSpeed)
{
CurrentSpeed = 0;
_carIsDead = true;
// Использовать ключевое слово throw для генерации
.
// исключения и возврата в вызывающий код
throw new Exception($"{PetName} has overheated!")
{
HelpLink = "http://www.CarsRUs.com"
};
}
Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
}
}
Теперь можно обновить логику в блоке
catch
для вывода на консоль информации из свойства HelpLink
:
catch(Exception e)
{
...
Console.WriteLine("Help Link: {0}", e.HelpLink);
}
Свойство
Data
класса System.Exception
позволяет заполнить объект исключения подходящей вспомогательной информацией (такой как отметка времени). Свойство Data
возвращает объект, который реализует интерфейс по имени IDictionary
, определенный в пространстве имен System.Collections
. В главе 8 исследуется роль программирования на основе интерфейсов, а также рассматривается пространство имен System.Collections
. В текущий момент важно понимать лишь то, что словарные коллекции позволяют создавать наборы значений, извлекаемых по ключу. Взгляните на очередное изменение метода Car.Accelerate()
:
public void Accelerate(int delta)
{
if (_carIsDead)
{
Console.WriteLine("{0} is out of order...", PetName);
}
else
{
CurrentSpeed += delta;
if (CurrentSpeed >= MaxSpeed)
{
Console.WriteLine("{0} has overheated!", PetName);
CurrentSpeed = 0;
_carIsDead = true;
// Использовать ключевое слово throw для генерации
// исключения и возврата в вызывающий код.
throw new Exception($"{PetName} has overheated!")
{
HelpLink = "http://www.CarsRUs.com",
Data = {
{"TimeStamp",$"The car exploded at {DateTime.Now}"},
{"Cause","You have a lead foot."}
}
};
}
Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
}
}
С целью успешного прохода по парам "ключ-значение" добавьте директиву
using
для пространства имен System.Collections
, т.к. в файле с операторами верхнего уровня будет применяться тип DictionaryEntry
:
using System.Collections;
Затем обновите логику в блоке
catch
, чтобы обеспечить проверку значения, возвращаемого из свойства Data
, на равенство null
(т.е. стандартному значению). После этого свойства Key
и Value
типа DictionaryEntry
используются для вывода специальных данных на консоль:
catch (Exception e)
{
...
Console.WriteLine("\n-> Custom Data:");
foreach (DictionaryEntry de in e.Data)
{
Console.WriteLine("-> {0}: {1}", de.Key, de.Value);
}
}
Вот как теперь выглядит финальный вывод программы:
***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
*** Error! ***
Member name: Void Accelerate(Int32)
Class defining member: SimpleException.Car
Member type: Method
Message: Zippy has overheated!
Source: SimpleException
Stack: at SimpleException.Car.Accelerate(Int32 delta) ...
at SimpleException.Program.Main(String[] args) ...
Help Link: http://www.CarsRUs.com
-> Custom Data:
-> TimeStamp: The car exploded at 3/15/2020 16:22:59
-> Cause: You have a lead foot.
***** Out of exception logic *****
Свойство
Data
удобно в том смысле, что оно позволяет упаковывать специальную информацию об ошибке, не требуя построения нового типа класса для расширения базового класса Exception
. Тем не менее, каким бы полезным ни было свойство Data
, разработчики все равно обычно строят строго типизированные классы исключений, которые поддерживают специальные данные, применяя строго типизированные свойства.
Такой подход позволяет вызывающему коду перехватывать конкретный тип, производный от
Exception
, а не углубляться в коллекцию данных с целью получения дополнительных деталей. Чтобы понять, как это работает, необходимо разобраться с разницей между исключениями уровня системы и уровня приложения.
В библиотеках базовых классов .NET 5 определено много классов, которые в конечном итоге являются производными от
System.Exception
.
Например, в пространстве имен
System
определены основные объекты исключений, такие как ArgumentOutOfRangeException
, IndexOutOfRangeException
, StackOverflowException
и т.п. В других пространствах имен есть исключения, которые отражают поведение этих пространств имен. Например, в System.Drawing.Printing
определены исключения, связанные с печатью, в System.IO
— исключения, возникающие во время ввода-вывода, в System.Data
— исключения, специфичные для баз данных, и т.д.
Исключения, которые генерируются самой платформой .NET 5, называются системными исключениями. Такие исключения в общем случае рассматриваются как неисправимые фатальные ошибки. Системные исключения унаследованы прямо от базового класса
System.SystemException
, который в свою очередь порожден от System.Exception
(а тот — от класса System.Object
):
public class SystemException : Exception
{
// Various constructors.
}
Учитывая, что тип
System.SystemException
не добавляет никакой дополнительной функциональности кроме набора специальных конструкторов, вас может интересовать, по какой причине он вообще существует. Попросту говоря, когда тип исключения является производным от System.SystemException
, то есть возможность выяснить, что исключение сгенерировала исполняющая среда .NET 5, а не кодовая база выполняющегося приложения. Это довольно легко проверить, используя ключевое слово is
:
// Верно! NullReferenceException является SystemException.
NullReferenceException nullRefEx = new NullReferenceException();
Console.WriteLine(
"NullReferenceException is-a SystemException? : {0}",
nullRefEx is SystemException);
Поскольку все исключения .NET 5 являются типами классов, вы можете создавать собственные исключения, специфичные для приложения. Однако из-за того, что базовый класс
System.SystemException
представляет исключения, генерируемые исполняющей средой, может сложиться впечатление, что вы должны порождать свои специальные исключения от типа System.Exception
. Конечно, можно поступать и так, но взамен их лучше наследовать от класса System.ApplicationException
:
public class ApplicationException : Exception
{
// Разнообразные конструкторы.
}
Как и в
SystemException
, кроме набора конструкторов никаких дополнительных членов в классе ApplicationException
не определено. С точки зрения функциональности единственная цель класса System.ApplicationException
состоит в идентификации источника ошибки. При обработке исключения, производного от System.ApplicationException
, можно предполагать, что исключение было сгенерировано кодовой базой выполняющегося приложения, а не библиотеками базовых классов .NET Core либо исполняющей средой .NET 5.
Наряду с тем, что для сигнализации об ошибке во время выполнения можно всегда генерировать экземпляры
System.Exception
(как было показано в первом примере), иногда предпочтительнее создавать строго типизированное исключение, которое представляет уникальные детали, связанные с текущей проблемой.
Например, предположим, что вы хотите построить специальное исключение (по имени
CarIsDeadException
) для представления ошибки, которая возникает из-за увеличения скорости обреченного на выход из строя автомобиля. Первым делом создается новый класс, унаследованный от System.Exception/System.ApplicationException
(по соглашению имена всех классов исключений заканчиваются суффиксом Exception
).
На заметку! Согласно правилу все специальные классы исключений должны быть определены как открытые (вспомните, что стандартным модификатором доступа для невложенных типов является
internal
). Причина в том, что исключения часто передаются за границы сборок и потому должны быть доступны вызывающей кодовой базе.
Создайте новый проект консольного приложения по имени
CustomException
, скопируйте в него предыдущие файлы Car.cs
и Radio.cs
и измените название пространства имен, в котором определены типы Car
и Radio
, с SimpleException
на CustomException
.
Затем добавьте в проект новый файл по имени
CarIsDeadException.cs
и поместите в него следующее определение класса:
using System;
namespace CustomException
{
// Это специальное исключение описывает детали условия
// выхода автомобиля из строя .
// (Не забывайте, что можно также просто расширить класс Exception.)
public class CarIsDeadException : ApplicationException
{
}
}
Как и с любым классом, вы можете создавать произвольное количество специальных членов, к которым можно обращаться внутри блока
catch
в вызывающем коде. Кроме того, вы можете также переопределять любые виртуальные члены, определенные в родительских классах. Например, вы могли бы реализовать CarIsDeadException
, переопределив виртуальное свойство Message
.
Вместо заполнения словаря данных (через свойство
Data
) при генерировании исключения конструктор позволяет указывать отметку времени и причину возникновения ошибки Наконец, отметку времени и причину возникновения ошибки можно получить с применением строго типизированных свойств:
public class CarIsDeadException : ApplicationException
{
private string _messageDetails = String.Empty;
public DateTime ErrorTimeStamp {get; set;}
public string CauseOfError {get; set;}
public CarIsDeadException(){}
public CarIsDeadException(string message,
string cause, DateTime time)
{
_messageDetails = message;
CauseOfError = cause;
ErrorTimeStamp = time;
}
// Переопределить свойство Exception.Message.
public override string Message
=> $"Car Error Message: {_messageDetails}";
}
Здесь класс
CarIsDeadException
поддерживает закрытое поле (_messageDetails
), которое представляет данные, касающиеся текущего исключения; его можно устанавливать с использованием специального конструктора. Сгенерировать такое исключение в методе Accelerate()
несложно. Понадобится просто создать, сконфигурировать и сгенерировать объект CarIsDeadException
, а не System.Exception
:
// Сгенерировать специальное исключение CarIsDeadException.
public void Accelerate(int delta)
{
...
throw new CarIsDeadException(
$"{PetName} has overheated!",
"You have a lead foot", DateTime.Now)
{
HelpLink = "http://www.CarsRUs.com",
};
...
}
Для перехвата такого входного исключения блок
catch
теперь можно модифицировать, чтобы в нем перехватывался конкретный тип CarIsDeadException
(тем не менее, с учетом того, что System.CarIsDeadException
"является" System.Exception
, по-прежнему допустимо перехватывать System.Exception
):
using System;
using CustomException;
Console.WriteLine("***** Fun with Custom Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
// Отслеживать исключение.
myCar.Accelerate(50);
}
catch (CarIsDeadException e)
{
Console.WriteLine(e.Message);
Console.WriteLine(e.ErrorTimeStamp);
Console.WriteLine(e.CauseOfError);
}
Console.ReadLine();
Итак, теперь, когда вы понимаете базовый процесс построения специального исключения, пришло время опереться на эти знания.
В текущем классе
CarIsDeadException
переопределено виртуальное свойство System.Exception.Message
с целью конфигурирования специального сообщения об ошибке и предоставлены два специальных свойства для учета дополнительных порций данных. Однако в реальности переопределять виртуальное свойство Message
не обязательно, т.к. входное сообщение можно просто передать конструктору родительского класса:
public class CarIsDeadException : ApplicationException
{
public DateTime ErrorTimeStamp { get; set; }
public string CauseOfError { get; set; }
public CarIsDeadException() { }
// Передача сообщения конструктору родительского класса.
public CarIsDeadException(string message, string cause, DateTime time)
:base(message)
{
CauseOfError = cause;
ErrorTimeStamp = time;
}
}
Обратите внимание, что на этот раз не объявляется строковая переменная для представления сообщения и не переопределяется свойство
Message
. Взамен нужный параметр просто передается конструктору базового класса. При таком проектном решении специальный класс исключения является всего лишь уникально именованным классом, производным от System.ApplicationException
(с дополнительными свойствами в случае необходимости), который не переопределяет какие-либо члены базового класса.
Не удивляйтесь, если большинство специальных классов исключений (а то и все) будет соответствовать такому простому шаблону. Во многих случаях роль специального исключения не обязательно связана с предоставлением дополнительной функциональности помимо той, что унаследована от базовых классов. На самом деле цель в том, чтобы предложить строго именованный тип, который четко идентифицирует природу ошибки, благодаря чему клиент может реализовать отличающуюся логику обработки для разных типов исключений.
Если вы хотите создать по-настоящему интересный специальный класс исключения, тогда необходимо обеспечить наличие у класса следующих характеристик:
• он является производным от класса
Exception/ApplicationException
;
• в нем определен стандартный конструктор;
• в нем определен конструктор, который устанавливает значение унаследованного свойства
Message
;
• в нем определен конструктор для обработки "внутренних исключений".
Чтобы завершить исследование специальных исключений, ниже приведена последняя версия класса
CarIsDeadException
, в которой реализованы все упомянутые выше специальные конструкторы (свойства будут такими же, как в предыдущем примере):
public class CarIsDeadException : ApplicationException
{
private string _messageDetails = String.Empty;
public DateTime ErrorTimeStamp {get; set;}
public string CauseOfError {get; set;}
public CarIsDeadException(){}
public CarIsDeadException(string cause, DateTime time) :
this(cause,time,string.Empty)
{
}
public CarIsDeadException(string cause, DateTime time,
string message) :
this(cause,time,message, null)
{
}
public CarIsDeadException(string cause, DateTime time,
string message, System.Exception
inner) :
base(message, inner)
{
CauseOfError = cause;
ErrorTimeStamp = time;
}
}
Затем необходимо модифицировать метод
Accelerate()
с учетом обновленного специального исключения:
throw new CarIsDeadException("You have a lead foot",
DateTime.Now,$"{PetName} has overheated!")
{
HelpLink = "http://www.CarsRUs.com",
};
Поскольку создаваемые специальные исключения, следующие установившейся практике в .NET Core, на самом деле отличаются только своими именами, полезно знать, что среды Visual Studio и Visual Studio Code предлагает фрагмент кода, который автоматически генерирует новый класс исключения, отвечающий рекомендациям .NET. Для его активизации наберите
ехс
и нажмите клавишу <ТаЬ> (в Visual Studio нажмите <Tab> два раза).
В своей простейшей форме блок
try
сопровождается единственным блоком catch
. Однако в реальности часто приходится сталкиваться с ситуациями, когда операторы внутри блока try
могут генерировать многочисленные исключения. Создайте новый проект консольного приложения на C# по имени ProcessMultipleExpceptions
, скопируйте в него файлы Car.cs
, Radio.cs
и CarIsDeadException.cs
из предыдущего проекта CustomException
и надлежащим образом измените название пространства имен.
Затем модифицируйте метод
Accelerate()
класса Car
так, чтобы он генерировал еще и предопределенное в библиотеках базовых классов исключение ArgumentOutOfRangeException
, если передается недопустимый параметр (которым будет считаться любое значение меньше нуля). Обратите внимание, что конструктор этого класса исключения принимает имя проблемного аргумента в первом параметре типа string
, за которым следует сообщение с описанием ошибки.
// Перед продолжением проверить аргумент на предмет допустимости.
public void Accelerate(int delta)
{
if (delta < 0)
{
throw new ArgumentOutOfRangeException(nameof(delta),
"Speed must be greater than zero");
// Значение скорости должно быть больше нуля!
}
...
}
На заметку! Операция
nameof()
возвращает строку, представляющую имя объекта, т.е. переменную delta
в рассматриваемом примере. Такой прием позволяет безопасно ссылаться на объекты, методы и переменные С#, когда требуются их строковые версии.
Теперь логика в блоке
catch
может реагировать на каждый тип исключения специфическим образом:
using System;
using System.IO;
using ProcessMultipleExceptions;
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
// Вызвать исключение выхода за пределы диапазона аргумента.
myCar.Accelerate(-10);
}
catch (CarIsDeadException e)
{
Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine(e.Message);
}
Console.ReadLine();
При написании множества блоков
catch
вы должны иметь в виду, что когда исключение сгенерировано, оно будет обрабатываться первым подходящим блоком catch
. Чтобы проиллюстрировать, что означает "первый подходящий" блок catch
, модифицируйте предыдущий код, добавив еще один блок catch
, который пытается обработать все остальные исключения кроме CarIsDeadException
и ArgumentOutOfRangeException
путем перехвата общего типа System.Exception
:
// Этот код не скомпилируется!
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
// Вызвать исключение выхода за пределы диапазона аргумента.
myCar.Accelerate(-10);
}
catch(Exception e)
{
// Обработать все остальные исключения?
Console.WriteLine(e.Message);
}
catch (CarIsDeadException e)
{
Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine(e.Message);
}
Console.ReadLine();
Представленная выше логика обработки исключений приводит к возникновению ошибок на этапе компиляции. Проблема в том, что первый блок
catch
способен обрабатывать любые исключения, производные от System.Exception
(с учетом отношения "является"), в том числе CarIsDeadException
и ArgumentOutOfRangeException
. Следовательно, два последних блока catch
в принципе недостижимы!
Запомните эмпирическое правило: блоки
catch
должны быть структурированы так, чтобы первый catch
перехватывал наиболее специфическое исключение (т.е. производный тип, расположенный ниже всех в цепочке наследования типов исключений), а последний catch
— самое общее исключение (т.е. базовый класс имеющейся цепочки наследования: System.Exception
в данном случае).
Таким образом, если вы хотите определить блок
catch
, который будет обрабатывать любые исключения помимо CarIsDeadException
и ArgumentOutOfRangeException
, то можно было бы написать следующий код:
// Этот код скомпилируется без проблем.
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
// Вызвать исключение выхода за пределы диапазона аргумента.
myCar.Accelerate(-10);
}
catch (CarIsDeadException e)
{
Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine(e.Message);
}
// Этот блок будет перехватывать все остальные исключения
.
// помимо CarIsDeadException и ArgumentOutOfRangeException
catch (Exception e)
{
Console.WriteLine(e.Message);
}
Console.ReadLine();
На заметку! Везде, где только возможно, отдавайте предпочтение перехвату специфичных классов исключений, а не общего класса
System.Exception
. Хотя может показаться, что это упрощает жизнь в краткосрочной перспективе (поскольку охватывает все исключения, которые пока не беспокоят), в долгосрочной перспективе могут возникать странные аварийные отказы во время выполнения, т.к. в коде не была предусмотрена непосредственная обработка более серьезной ошибки. Не забывайте, что финальный блок catch
, который работает с System.Exception
, на самом деле имеет тенденцию быть чрезвычайно общим.
В языке C# также поддерживается "общий" контекст
catch
, который не получает явно объект исключения, сгенерированный заданным членом:
// Общий оператор catch.
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
myCar.Accelerate(90);
}
catch
{
Console.WriteLine("Something bad happened...");
// Произошло что-то плохое...
}
Console.ReadLine();
Очевидно, что это не самый информативный способ обработки исключений, поскольку нет никакой возможности для получения содержательных данных о возникшей ошибке (таких как имя метода, стек вызовов или специальное сообщение). Тем не менее, в C# такая конструкция разрешена, потому что она может быть полезной, когда требуется обрабатывать все ошибки в обобщенной манере.
Внутри логики блока
try
перехваченное исключение разрешено повторно сгенерировать для передачи вверх по стеку вызовов предшествующему вызывающему коду. Для этого просто используется ключевое слово throw
в блоке catch
. В итоге исключение передается вверх по цепочке вызовов, что может оказаться полезным, если блок catch
способен обработать текущую ошибку только частично:
// Передача ответственности.
...
try
{
// Логика увеличения скорости автомобиля...
}
catch(CarIsDeadException e)
{
// Выполнить частичную обработку этой ошибки и передать ответственность.
throw;
}
...
Имейте в виду, что в данном примере кода конечным получателем исключения
CarIsDeadException
будет исполняющая среда .NET 5, т.к. операторы верхнего уровня генерируют его повторно. По указанной причине конечному пользователю будет отображаться системное диалоговое окно с информацией об ошибке. Обычно вы будете повторно генерировать частично обработанное исключение для передачи вызывающему коду, который имеет возможность обработать входное исключение более элегантным образом.
Также обратите внимание на неявную повторную генерацию объекта
CarIsDeadException
с помощью ключевого слова throw
без аргументов. Дело в том, что здесь не создается новый объект исключения, а просто передается исходный объект исключения (со всей исходной информацией). Это позволяет сохранить контекст первоначального целевого объекта.
Как нетрудно догадаться, вполне возможно, что исключение сгенерируется во время обработки другого исключения. Например, пусть вы обрабатываете исключение
CarIsDeadException
внутри отдельного блока catch
и в ходе этого процесса пытаетесь записать данные трассировки стека в файл carErrors.txt
на диске С: (для получения доступа к типам, связанным с вводом-выводом, потребуется добавить директиву using
с пространством имен System.IO
):
catch(CarIsDeadException e)
{
// Попытка открытия файла carErrors.txt, расположенного на диске С:.
FileStream fs = File.Open(@"C:\carErrors.txt", FileMode.Open);
...
}
Если указанный файл на диске С: отсутствует, тогда вызов метода
File.Open()
приведет к генерации исключения FileNotFoundException
! Позже в книге, когда мы будем подробно рассматривать пространство имен System.IO
, вы узнаете, как программно определить, существует ли файл на жестком диске, перед попыткой его открытия (тем самым вообще избегая исключения). Однако чтобы не отклоняться от темы исключений, мы предположим, что такое исключение было сгенерировано.
Когда во время обработки исключения вы сталкиваетесь с еще одним исключением, установившаяся практика предусматривает обязательное сохранение нового объекта исключения как "внутреннего исключения" в новом объекте того же типа, что и исходное исключение. Причина, по которой необходимо создавать новый объект обрабатываемого исключения, связана с тем, что единственным способом документирования внутреннего исключения является применение параметра конструктора. Взгляните на следующий код:
using System.IO;
// Обновление обработчика исключений
catch (CarIsDeadException e)
{
try
{
FileStream fs =
File.Open(@"C:\carErrors.txt", FileMode.Open);
...
}
catch (Exception e2)
{
// Следующая строка приведет к ошибке на этапе компиляции,
// т.к. InnerException допускает только чтение.
// е.InnerException = е2;
// Сгенерировать исключение, которое записывает новое
// исключение, а также сообщение из первого исключения.
throw new CarIsDeadException(
e.CauseOfError, e.ErrorTimeStamp, e.Message, e2); }
}
Обратите внимание, что в данном случае конструктору
CarIsDeadException
во втором параметре передается объект FileNotFoundException
. После настройки этого нового объекта он передается вверх по стеку вызовов следующему вызывающему коду, которым в рассматриваемой ситуации будут операторы верхнего уровня.
Поскольку после операторов верхнего уровня нет "следующего вызывающего кода", который мог бы перехватить исключение, пользователю будет отображено системное диалоговое окно с сообщением об ошибке. Подобно повторной генерации исключения запись внутренних исключений обычно полезна, только если вызывающий код способен обработать исключение более элегантно. В таком случае внутри логики
catch
вызывающего кода можно использовать свойство InnerException
для извлечения деталей внутреннего исключения.
В области действия
try/catch
можно также определять дополнительный блок finally
. Целью блока finally
является обеспечение того, что заданный набор операторов будет выполняться всегда независимо от того, возникло исключение (любого типа) или нет. Для иллюстрации предположим, что перед завершением программы радиоприемник в автомобиле должен всегда выключаться вне зависимости от обрабатываемого исключения:
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
myCar.CrankTunes(true);
try
{
// Логика, связанная с увеличением скорости автомобиля.
}
catch(CarIsDeadException e)
{
// Обработать объект CarIsDeadException.
}
catch(ArgumentOutOfRangeException e)
{
// Обработать объект ArgumentOutOfRangeException.
}
catch(Exception e)
{
// Обработать любой другой объект Exception.
}
finally
{
// Это код будет выполняться всегда независимо
// от того, возникало исключение или нет.
myCar.CrankTunes(false);
}
Console.ReadLine();
Если вы не определите блок
finally
, то в случае генерации исключения радиоприемник не выключится (что может быть или не быть проблемой). В более реалистичном сценарии, когда необходимо освободить объекты, закрыть файл либо отсоединиться от базы данных (или чего-то подобного), блок finally
представляет собой подходящее место для выполнения надлежащей очистки.
В версии C# 6 появилась новая конструкция, которая может быть помещена в блок
catch
посредством ключевого слова when
. В случае ее добавления появляется возможность обеспечить выполнение операторов внутри блока catch
только при удовлетворении некоторого условия в коде. Выражение условия должно давать в результате булевское значение (true
или false
) и может быть указано с применением простого выражения в самом определении when
либо за счет вызова дополнительного метода в коде. Коротко говоря, такой подход позволяет добавлять "фильтры" к логике исключения.
Взгляните на показанную ниже модифицированную логику исключения. Здесь к обработчику
CarIsDeadException
добавлена конструкция when
, которая гарантирует, что данный блок catch
никогда не будет выполняться по пятницам (конечно, пример надуман, но кто захочет разбирать автомобиль на выходные?). Обратите внимание, что одиночное булевское выражение в конструкции when
должно быть помещено в круглые скобки.
catch (CarIsDeadException e)
when (e.ErrorTimeStamp.DayOfWeek != DayOfWeek.Friday)
{
// Выводится, только если выражение в конструкции when
// вычисляется как true.
Console.WriteLine("Catching car is dead!");
Console.WriteLine(e.Message);
}
Рассмотренный пример был надуманным, а более реалистичное использование фильтра исключений предусматривает перехват экземпляров
SystemException
. Скажем, пусть ваш код сохраняет информацию в базу данных и генерируется общее исключение. Изучив сообщение и детали исключения, вы можете создать специфические обработчики, основанные на том, что конкретно было причиной исключения.
Среда Visual Studio предлагает набор инструментов, которые помогают отлаживать необработанные исключения. Предположим, что вы увеличили скорость объекта
Car
до значения, превышающего максимум, но на этот раз не позаботились о помещении вызова внутрь блока try
:
Car myCar = new Car("Rusty", 90);
myCar.Accelerate(100);
Если вы запустите сеанс отладки в Visual Studio (выбрав пункт меню Debugs►Start (Отладка►Начать)), то во время генерации необработанного исключения произойдет автоматический останов. Более того, откроется окно (рис. 7.1), отображающее значение свойства
Message
.
На заметку! Если вы не обработали исключение, сгенерированное каким-то методом из библиотек базовых классов .NET 5, тогда отладчик Visual Studio остановит выполнение на операторе, который вызвал проблемный метод.
Щелкнув в этом окне на ссылке View Detail (Показать подробности), вы обнаружите подробную информацию о состоянии объекта (рис. 7.2).
В главе была раскрыта роль структурированной обработки исключений. Когда методу необходимо отправить объект ошибки вызывающему коду, он должен создать, сконфигурировать и сгенерировать специфичный объект производного от
System.Exception
типа посредством ключевого слова throw
языка С#. Вызывающий код может обрабатывать любые входные исключения с применением ключевого слова catch
и необязательного блока finally
. В версии C# 6 появилась возможность создавать фильтры исключений с использованием дополнительного ключевого слова when
, а в версии C# 7 расширен перечень мест, где можно генерировать исключения.
Когда вы строите собственные специальные исключения, то в конечном итоге создаете класс, производный от класса
System.ApplicationException
, который обозначает исключение, генерируемое текущим выполняющимся приложением. В противоположность этому объекты ошибок, производные от класса System.SystemException
, представляют критические (и фатальные) ошибки, генерируемые исполняющей средой .NET 5. Наконец, в главе были продемонстрированы разнообразные инструменты среды Visual Studio, которые можно применять для создания специальных исключений (согласно установившейся практике .NET), а также для отладки необработанных исключений.
Материал настоящей главы опирается на ваши текущие знания объектно-ориентированной разработки и посвящен теме программирования на основе интерфейсов. Вы узнаете, как определять и реализовывать интерфейсы, а также ознакомитесь с преимуществами построения типов, которые поддерживают несколько линий поведения. В ходе изложения обсуждаются связанные темы, такие как получение ссылок на интерфейсы, явная реализация интерфейсов и построение иерархий интерфейсов. Будет исследовано несколько стандартных интерфейсов, определенных внутри библиотек базовых классов .NET Core. Кроме того, раскрываются новые средства C# 8, связанные с интерфейсами, в том числе стандартные методы интерфейсов, статические члены и модификаторы доступа. Вы увидите, что специальные классы и структуры могут реализовывать эти предопределенные интерфейсы для поддержки ряда полезных аспектов поведения, включая клонирование, перечисление и сортировку объектов.
Первым делом давайте ознакомимся с формальным определением интерфейсного типа, которое с появлением версии C# 8 изменилось. До выхода C# 8 интерфейс был не более чем именованным набором абстрактных членов. Вспомните из главы 6, что абстрактные методы являются чистым протоколом, поскольку они не предоставляют свои стандартные реализации. Специфичные члены, определяемые интерфейсом, зависят от того, какое точно поведение он моделирует. Другими словами, интерфейс выражает поведение, которое заданный класс или структура может избрать для поддержки. Более того, далее в главе вы увидите, что класс или структура может реализовывать столько интерфейсов, сколько необходимо, и посредством этого поддерживать по существу множество линий поведения.
Средство стандартных методов интерфейсов, введенное в C# 8.0, позволяет методам интерфейса содержать реализацию, которая может переопределяться или не переопределяться в классе реализации. Более подробно о таком средстве речь пойдет позже в главе.
Как вы наверняка догадались, библиотеки базовых классов .NET Core поставляются с многочисленными предопределенными интерфейсными типами, которые реализуются разнообразными классами и структурами. Например, в главе 21 будет показано, что инфраструктура ADO.NET содержит множество поставщиков данных, которые позволяют взаимодействовать с определенной системой управления базами данных. Таким образом, в ADO.NET на выбор доступен обширный набор классов подключений (
SqlConnection
, OleDbConnection
, OdbcConnection
и т.д.). Вдобавок независимые поставщики баз данных (а также многие проекты с открытым кодом) предлагают библиотеки .NET Core для взаимодействия с большим числом других баз данных (MySQL, Oracle и т.д.), которые содержат объекты, реализующие упомянутые интерфейсы.
Невзирая на тот факт, что каждый класс подключения имеет уникальное имя, определен в отдельном пространстве имен и (в некоторых случаях) упакован в отдельную сборку, все они реализуют общий интерфейс под названием
IDbConnection
:
// Интерфейс IDbConnection определяет общий набор членов,
// поддерживаемый всеми классами подключения.
public interface IDbConnection : IDisposable
{
// Методы
IDbTransaction BeginTransaction();
IDbTransaction BeginTransaction(IsolationLevel il);
void ChangeDatabase(string databaseName);
void Close();
IDbCommand CreateCommand();
void Open();
// Свойства
string ConnectionString { get; set;}
int ConnectionTimeout { get; }
string Database { get; }
ConnectionState State { get; }
}
На заметку! По соглашению имена интерфейсов .NET снабжаются префиксом в виде заглавной буквы
I
. При создании собственных интерфейсов рекомендуется также следовать этому соглашению.
В настоящий момент детали того, что делают члены интерфейса
IDbConnection
, не важны. Просто запомните, что в IDbConnection
определен набор членов, которые являются общими для всех классов подключений ADO.NET. В итоге каждый класс подключения гарантированно поддерживает такие члены, как Open()
, Close()
, CreateCommand()
и т.д. Кроме того, поскольку методы интерфейса IDbConnection
всегда абстрактные, в каждом классе подключения они могут быть реализованы уникальным образом.
В оставшихся главах книги вы встретите десятки интерфейсов, поставляемых в библиотеках базовых классов .NET Core. Вы увидите, что эти интерфейсы могут быть реализованы в собственных специальных классах и структурах для определения типов, которые тесно интегрированы с платформой. Вдобавок, как только вы оцените полезность интерфейсных типов, вы определенно найдете причины для построения собственных таких типов.
Учитывая материалы главы 6, интерфейсный тип может выглядеть кое в чем похожим на абстрактный базовый класс. Вспомните, что когда класс помечен как абстрактный, он может определять любое количество абстрактных членов для предоставления полиморфного интерфейса всем производным типам. Однако даже если класс действительно определяет набор абстрактных членов, он также может определять любое количество конструкторов, полей данных, неабстрактных членов (с реализацией) и т.д. Интерфейсы (до C# 8.0) содержат только определения членов. Начиная с версии C# 8, интерфейсы способны содержать определения членов (вроде абстрактных членов), члены со стандартными реализациями (наподобие виртуальных членов) и статические члены. Есть только два реальных отличия: интерфейсы не могут иметь нестатические конструкторы, а класс может реализовывать множество интерфейсов. Второй аспект обсуждается следующим.
Полиморфный интерфейс, устанавливаемый абстрактным родительским классом, обладает одним серьезным ограничением: члены, определенные абстрактным родительским классом, поддерживаются только производными типами. Тем не менее, в крупных программных системах часто разрабатываются многочисленные иерархии классов, не имеющие общего родителя кроме
System.Object
. Учитывая, что абстрактные члены в абстрактном базовом классе применимы только к производным типам, не существует какого-то способа конфигурирования типов в разных иерархиях для поддержки одного и того же полиморфного интерфейса. Для начала создайте новый проект консольного приложения по имени СиstomInterfaces
. Добавьте к проекту следующий абстрактный класс:
namespace CustomInterfaces
{
public abstract class CloneableType
{
// Поддерживать этот "полиморфный интерфейс"
.
// могут только производные типы.
// Классы в других иерархиях не имеют доступа
// к данному абстрактному члену
public abstract object Clone();
}
}
При таком определении поддерживать метод
Clone()
способны только классы, расширяющие CloneableType
. Если создается новый набор классов, которые не расширяют данный базовый класс, то извлечь пользу от такого полиморфного интерфейса не удастся. К тому же вы можете вспомнить, что язык C# не поддерживает множественное наследование для классов. По этой причине, если вы хотите создать класс MiniVan
, который является и Car
, и CloneableType
, то поступить так, как показано ниже, не удастся:
// Недопустимо! Множественное наследование для классов в C# невозможно
public class MiniVan : Car, CloneableType
{
}
Несложно догадаться, что на помощь здесь приходят интерфейсные типы. После того как интерфейс определен, он может быть реализован любым классом либо структурой, в любой иерархии и внутри любого пространства имен или сборки (написанной на любом языке программирования .NET Core). Как видите, интерфейсы являются чрезвычайно полиморфными. Рассмотрим стандартный интерфейс .NET Core под названием
ICloneable
, определенный в пространстве имен System
. В нем определен единственный метод по имени Clone()
:
public interface ICloneable
{
object Clone();
}
Во время исследования библиотек базовых классов .NET Core вы обнаружите, что интерфейс
ICloneable
реализован очень многими на вид несвязанными типами (System.Array
, System.Data.SqlClient.SqlConnection
, System.OperatingSystem
, System.String
и т.д.). Хотя указанные типы не имеют общего родителя (кроме System.Object
), их можно обрабатывать полиморфным образом посредством интерфейсного типа ICloneable
. Первым делом поместите в файл Program.cs
следующий код:
using System;
using CustomInterfaces;
Console.WriteLine("***** A First Look at Interfaces *****\n");
CloneableExample();
Далее добавьте к операторам верхнего уровня показанную ниже локальную функцию по имени
CloneMe()
, которая принимает параметр типа ICloneable
, что позволит передавать любой объект, реализующий указанный интерфейс:
static void CloneableExample()
{
// Все эти классы поддерживают интерфейс ICloneable.
string myStr = "Hello";
OperatingSystem unixOS =
new OperatingSystem(PlatformID.Unix, new Version());
// Следовательно, все они могут быть переданы методу,
// принимающему параметр типа ICloneable.
CloneMe(myStr);
CloneMe(unixOS);
static void CloneMe(ICloneable c)
{
// Клонировать то, что получено, и вывести имя.
object theClone = c.Clone();
Console.WriteLine("Your clone is a: {0}",
theClone.GetType().Name);
}
}
После запуска приложения в окне консоли выводится имя каждого класса, полученное с помощью метода
GetType()
, который унаследован от System.Object
. Как будет объясняться в главе 17, этот метод позволяет выяснить строение любого типа во время выполнения. Вот вывод предыдущей программы:
***** A First Look at Interfaces *****
Your clone is a: String
Your clone is a: OperatingSystem
Еще одно ограничение абстрактных базовых классов связано с тем, что каждый производный тип должен предоставлять реализацию для всего набора абстрактных членов. Чтобы увидеть, в чем заключается проблема, вспомним иерархию фигур, которая была определена в главе 6. Предположим, что в базовом классе
Shape
определен новый абстрактный метод по имени GetNumberOfPoints()
, который позволяет производным типам возвращать количество вершин, требуемых для визуализации фигуры:
namespace CustomInterfaces
{
abstract class Shape
{
...
// Теперь этот метод обязан поддерживать каждый производный класс!
public abstract byte GetNumberOfPoints();
}
}
Очевидно, что единственным классом, который в принципе имеет вершины, будет
Hexagon
. Однако теперь из-за внесенного обновления каждый производный класс (Circle
, Hexagon
и ThreeDCircle
) обязан предоставить конкретную реализацию метода GetNumberOfPoints()
, даже если в этом нет никакого смысла. И снова интерфейсный тип предлагает решение. Если вы определите интерфейс, который представляет поведение "наличия вершин", то можно будет просто подключить его к классу Hexagon
, оставив классы Circle
и ThreeDCircle
незатронутыми.
На заметку! Изменения интерфейсов в версии C# 8 являются, по всей видимости, наиболее существенными изменениями существующего языка за весь обозримый период. Как было ранее описано, новые возможности интерфейсов значительно приближают их функциональность к функциональности абстрактных классов с добавочной способностью классов реализовывать множество интерфейсов. В этой области рекомендуется проявлять надлежащую осторожность и здравый смысл. Один лишь факт, что вы можете что-то делать, вовсе не означает, что вы обязаны поступать так.
Теперь, когда вы лучше понимаете общую роль интерфейсных типов, давайте рассмотрим пример определения и реализации специальных интерфейсов. Скопируйте файлы
Shape.cs
, Hexagon.cs
, Circle.cs
и ThreeDCircle.cs
из решения Shapes
, созданного в главе 6. Переименуйте пространство имен, в котором определены типы, связанные с фигурами, в CustomInterfасе
(просто чтобы избежать импортирования в новый проект определений пространства имен). Добавьте в проект новый файл по имени IPointy.cs
.
На синтаксическом уровне интерфейс определяется с использованием ключевого слова
interface
языка С#. В отличие от классов для интерфейсов никогда не задается базовый класс (даже System.Object
; тем не менее, как будет показано позже в главе, можно задавать базовые интерфейсы). До выхода C# 8.0 для членов интерфейса не указывались модификаторы доступа (т.к. все члены интерфейса были неявно открытыми и абстрактными). В версии C# 8.0 можно также определять члены private
, internal
, protected
и даже static
, о чем пойдет речь далее в главе. Ниже приведен пример определения специального интерфейса в С#:
namespace CustomInterfaces
{
// Этот интерфейс определяет поведение "наличия вершин".
public interface IPointy
{
// Неявно открытый и абстрактный.
byte GetNumberOfPoints();
}
}
В интерфейсах в C# 8 нельзя определять поля данных или нестатические конструкторы. Таким образом, следующая версия интерфейса
IPointy
приведет к разнообразным ошибкам на этапе компиляции:
// Внимание! В этом коде полно ошибок!
public interface IPointy
{
// Ошибка! Интерфейсы не могут иметь поля данных!
public int numbOfPoints;
// Ошибка! Интерфейсы не могут иметь нестатические конструкторы!
public IPointy() { numbOfPoints = 0;}
}
В начальной версии интерфейса
IPointy
определен единственный метод. В интерфейсных типах допускается также определять любое количество прототипов свойств. Например, интерфейс IPointy
можно было бы обновить, как показано ниже, закомментировав свойство для чтения-записи и добавив свойство только для чтения. Свойство Points
заменяет метод GetNumberOfPoints()
.
// Поведение "наличия вершин" в виде свойства только для чтения.
public interface IPointy
{
// Неявно public и abstract.
// byte GetNumberOfPoints();
// Свойство, поддерживающее чтение и запись,
// в интерфейсе может выглядеть так:
// string PropName { get; set; }
// Тогда как свойство только для записи - так:
byte Points { get; }
}
На заметку! Интерфейсные типы также могут содержать определения событий (глава 12) и индексаторов (глава 11).
Сами по себе интерфейсные типы совершенно бесполезны, поскольку выделять память для них, как делалось бы для класса или структуры, невозможно:
// Внимание! Выделять память для интерфейсных типов не допускается!
IPointy p = new IPointy(); // Ошибка на этапе компиляции!
Интерфейсы не привносят ничего особого до тех пор, пока не будут реализованы классом или структурой. Здесь
IPointy
представляет собой интерфейс, который выражает поведение "наличия вершин". Идея проста: одни классы в иерархии фигур (например, Hexagon
) имеют вершины, в то время как другие (вроде Circle
) — нет.
Когда функциональность класса (или структуры) решено расширить за счет поддержки интерфейсов, к определению добавляется список нужных интерфейсов, разделенных запятыми. Имейте в виду, что непосредственный базовый класс должен быть указан первым сразу после операции двоеточия. Если тип класса порождается напрямую от
System.Object
, тогда вы можете просто перечислить интерфейсы, поддерживаемые классом, т.к. компилятор C# будет считать, что типы расширяют System.Object
, если не задано иначе. К слову, поскольку структуры всегда являются производными от класса System.ValueType
(см. главу 4), достаточно указать список интерфейсов после определения структуры. Взгляните на приведенные ниже примеры:
// Этот класс является производными от System.Object
// и реализует единственный интерфейс.
public class Pencil : IPointy
{...}
// Этот класс также является производными от System.Object
// и реализует единственный интерфейс.
public class SwitchBlade : object, IPointy
{...}
// Этот класс является производными от специального базового
// класса и реализует единственный интерфейс.
public class Fork : Utensil, IPointy
{...}
// Эта структура неявно является производной
// от System.ValueType и реализует два интерфейса.
public struct PitchFork : ICloneable, IPointy
{...}
Важно понимать, что для интерфейсных элементов, которые не содержат стандартной реализации, реализация интерфейса работает по плану "все или ничего". Поддерживающий тип не имеет возможности выборочно решать, какие члены он будет реализовывать. Учитывая, что интерфейс
IPointy
определяет единственное свойство только для чтения, накладные расходы невелики. Тем не менее, если вы реализуете интерфейс, который определяет десять членов (вроде показанного ранее IDbConnection
), тогда тип отвечает за предоставление деталей для всех десяти абстрактных членов.
В текущем примере добавьте к проекту новый тип класса по имени
Triangle
, который "является" Shape
и поддерживает IPointy
. Обратите внимание, что реализация доступного только для чтения свойства Points
(реализованного с использованием синтаксиса членов, сжатых до выражений) просто возвращает корректное количество вершин (т.е. 3):
using System;
namespace CustomInterfaces
{
// Новый класс по имени Triangle, производный от Shape.
class Triangle : Shape, IPointy
{
public Triangle() { }
public Triangle(string name) : base(name) { }
public override void Draw()
{
Console.WriteLine("Drawing {0} the Triangle", PetName);
}
// Реализация IPointy.
// public byte Points
// {
// get { return 3; }
// }
public byte Points => 3;
}
}
Модифицируйте существующий тип
Hexagon
, чтобы он также поддерживал интерфейс IPointy
:
using System;
namespace CustomInterfaces
{
// Hexagon теперь реализует IPointy.
class Hexagon : Shape, IPointy
{
public Hexagon(){ }
public Hexagon(string name) : base(name){ }
public override void Draw()
{
Console.WriteLine("Drawing {0} the Hexagon", PetName);
}
// Реализация IPointy.
public byte Points => 6;
}
}
Подводя итоги тому, что сделано к настоящему моменту, на рис. 8.1 приведена диаграмма классов в Visual Studio, где все совместимые с
IPointy
классы представлены с помощью популярной системы обозначений в виде "леденца на палочке". Еще раз обратите внимание, что Circle
и ThreeDCircle
не реализуют IPointy
, поскольку такое поведение в этих классах не имеет смысла.
На заметку! Чтобы скрыть или отобразить имена интерфейсов в визуальном конструкторе классов, щелкните правой кнопкой мыши на значке, представляющем интерфейс, и выберите в контекстном меню пункт Collapse (Свернуть) или Expand (Развернуть).
Теперь, имея несколько классов, которые поддерживают интерфейс
IPointy
, необходимо выяснить, каким образом взаимодействовать с новой функциональностью. Самый простой способ взаимодействия с функциональностью, предоставляемой заданным интерфейсом, заключается в обращении к его членам прямо на уровне объектов (при условии, что члены интерфейса не реализованы явно, о чем более подробно пойдет речь в разделе "Явная реализация интерфейсов" далее в главе). Например, взгляните на следующий код:
Console.WriteLine("***** Fun with Interfaces *****\n");
// Обратиться к свойству Points, определенному в интерфейсе IPointy.
Hexagon hex = new Hexagon();
Console.WriteLine("Points: {0}", hex.Points);
Console.ReadLine();
Данный подход нормально работает в этом конкретном случае, т.к. здесь точно известно, что тип
Hexagon
реализует упомянутый интерфейс и, следовательно, имеет свойство Points
. Однако в других случаях определить, какие интерфейсы поддерживаются конкретным типом, может быть нереально. Предположим, что есть массив, содержащий 50 объектов совместимых с Shape
типов, и только некоторые из них поддерживают интерфейс IPointy
. Очевидно, что если вы попытаетесь обратиться к свойству Points
для типа, который не реализует IPointy
, то возникнет ошибка. Как же динамически определить, поддерживает ли класс или структура подходящий интерфейс?
Один из способов выяснить во время выполнения, поддерживает ли тип конкретный интерфейс, предусматривает применение явного приведения. Если тип не поддерживает запрашиваемый интерфейс, то генерируется исключение
InvalidCastException
. В случае подобного рода необходимо использовать структурированную обработку исключений:
...
// Перехватить возможное исключение InvalidCastException.
Circle c = new Circle("Lisa");
IPointy itfPt = null;
try
{
itfPt = (IPointy)c;
Console.WriteLine(itfPt.Points);
}
catch (InvalidCastException e)
{
Console.WriteLine(e.Message);
}
Console.ReadLine();
Хотя можно было бы применить логику
try/catch
и надеяться на лучшее, в идеале хотелось бы определять, какие интерфейсы поддерживаются, до обращения к их членам. Давайте рассмотрим два способа, с помощью которых этого можно добиться.
Для определения, поддерживает ли данный тип тот или иной интерфейс, можно использовать ключевое слово
as
, которое было представлено в главе 6. Если объект может трактоваться как указанный интерфейс, тогда возвращается ссылка на интересующий интерфейс, а если нет, то ссылка null
. Таким образом, перед продолжением в коде необходимо реализовать проверку на предмет null
:
...
// Можно ли hex2 трактовать как IPointy?
Hexagon hex2 = new Hexagon("Peter");
IPointy itfPt2 = hex2 as IPointy;
if(itfPt2 != null)
{
Console.WriteLine("Points: {0}", itfPt2.Points);
}
else
{
Console.WriteLine("OOPS! Not pointy..."); // He реализует IPointy
}
Console.ReadLine();
Обратите внимание, что когда применяется ключевое слово
as
, отпадает необходимость в наличии логики try/catch
, т.к. если ссылка не является null
, то известно, что вызов происходит для действительной ссылки на интерфейс.
Проверить, реализован ли нужный интерфейс, можно также с помощью ключевого слова
is
(о котором впервые упоминалось в главе 6). Если интересующий объект не совместим с указанным интерфейсом, тогда возвращается значение false
. В случае предоставления в операторе имени переменной ей назначается надлежащий тип, что устраняет необходимость в проверке типа и выполнении приведения. Ниже показан обновленный предыдущий пример:
Console.WriteLine("***** Fun with Interfaces *****\n");
...
if(hex2 is IPointy itfPt3)
{
Console.WriteLine("Points: {0}", itfPt3.Points);
}
else
{
Console.WriteLine("OOPS! Not pointy...");
}
Console.ReadLine();
Как упоминалось ранее, в версии C# 8.0 методы и свойства интерфейса могут иметь стандартные реализации. Добавьте к проекту новый интерфейс по имени
IRegularPointy
, предназначенный для представления многоугольника заданной формы. Вот код интерфейса:
namespace CustomInterfaces
{
interface IRegularPointy : IPointy
{
int SideLength { get; set; }
int NumberOfSides { get; set; }
int Perimeter => SideLength * NumberOfSides;
}
}
Добавьте к проекту новый файл класса по имени
Square.cs
, унаследуйте класс от базового класса Shape
и реализуйте интерфейс IRegularPointy
:
namespace CustomInterfaces
{
class Square: Shape,IRegularPointy
{
public Square() { }
public Square(string name) : base(name) { }
// Метод Draw() поступает из базового класса Shape
public override void Draw()
{
Console.WriteLine("Drawing a square");
}
// Это свойство поступает из интерфейса IPointy
public byte Points => 4;
// Это свойство поступает из интерфейса IRegularPointy.
public int SideLength { get; set; }
public int NumberOfSides { get; set; }
// Обратите внимание, что свойство Perimeter не реализовано.
}
}
Здесь мы невольно попали в первую "ловушку", связанную с использованием стандартных реализаций интерфейсов. Свойство
Perimeter
, определенное в интерфейсе IRegularPointy
, в классе Square
не определено, что делает его недоступным экземпляру класса Square
. Чтобы удостовериться в этом, создайте новый экземпляр класса Square
и выведите на консоль соответствующие значения:
Console.WriteLine("\n***** Fun with Interfaces *****\n");
...
var sq = new Square("Boxy")
{NumberOfSides = 4, SideLength = 4};
sq.Draw();
// Следующий код не скомпилируется:
// Console.WriteLine($"{sq.PetName} has {sq.NumberOfSides} of length
{sq.SideLength} and a
perimeter of {sq.Perimeter}");
Взамен экземпляр
Square
потребуется явно привести к интерфейсу IRegularPointy
(т.к. реализация находится именно там) и тогда можно будет получать доступ к свойству Perimeter
. Модифицируйте код следующим образом:
Console.WriteLine($"{sq.PetName} has {sq.NumberOfSides} of length {sq.SideLength} and a
perimeter of {((IRegularPointy)sq).Perimeter}");
Один из способов обхода этой проблемы — всегда указывать интерфейс типа. Измените определение экземпляра
Square
, указав вместо типа Square
тип IRegularPointy
:
IRegularPointy sq = new Square("Boxy") {NumberOfSides = 4, SideLength = 4};
Проблема с таким подходом (в данном случае) связана с тем, что метод
Draw()
и свойство PetName
в интерфейсе не определены, а потому на этапе компиляции возникнут ошибки.
Хотя пример тривиален, он демонстрирует одну из проблем, касающихся стандартных реализаций. Прежде чем задействовать это средство в своем коде, обязательно оцените последствия того, что вызывающему коду должно быть известно, где находятся реализации.
Еще одним дополнением интерфейсов в C# 8.0 является возможность наличия в них статических конструкторов и членов, которые функционируют аналогично статическим членам в определениях классов, но определены в интерфейсах. Добавьте к интерфейсу
IRegularPointy
статическое свойство и статический конструктор:
interface IRegularPointy : IPointy
{
int SideLength { get; set; }
int NumberOfSides { get; set; }
int Perimeter => SideLength * NumberOfSides;
// Статические члены также разрешены в версии C# 8
static string ExampleProperty { get; set; }
static IRegularPointy() => ExampleProperty = "Foo";
}
Статические конструкторы не должны иметь параметры и могут получать доступ только к статическим свойствам и методам. Для обращения к статическому свойству интерфейса добавьте к операторам верхнего уровня следующий код:
Console.WriteLine($"Example property: {IRegularPointy.ExampleProperty}");
IRegularPointy.ExampleProperty = "Updated";
Console.WriteLine($"Example property: {IRegularPointy.ExampleProperty}");
Обратите внимание, что к статическому свойству необходимо обращаться через интерфейс, а не переменную экземпляра.
Учитывая, что интерфейсы являются допустимыми типами, можно строить методы, которые принимают интерфейсы в качестве параметров, как было проиллюстрировано на примере метода
CloneMe()
ранее в главе. Предположим, что вы определили в текущем примере еще один интерфейс по имени IDraw3D
:
namespace CustomInterfaces
{
// Моделирует способность визуализации типа в трехмерном виде.
public interface IDraw3D
{
void Draw3D();
}
}
Далее сконфигурируйте две из трех фигур (
Circle
и Hexagon
) с целью поддержки нового поведения:
// Circle поддерживает IDraw3D.
class ThreeDCircle : Circle, IDraw3D
{
...
public void Draw3D()
=> Console.WriteLine("Drawing Circle in 3D!"); }
}
// Hexagon поддерживает IPointy и IDraw3D.
class Hexagon : Shape, IPointy, IDraw3D
{
...
public void Draw3D()
=> Console.WriteLine("Drawing Hexagon in 3D!");
}
На рис. 8.2 показана обновленная диаграмма классов в Visual Studio.
Теперь если вы определите метод, принимающий интерфейс
IDraw3D
в качестве параметра, тогда ему можно будет передавать по существу любой объект, реализующий IDraw3D
. Попытка передачи типа, не поддерживающего необходимый интерфейс, приводит ошибке на этапе компиляции. Взгляните на следующий метод, определенный в классе Program
:
// Будет рисовать любую фигуру, поддерживающую IDraw3D.
static void DrawIn3D(IDraw3D itf3d)
{
Console.WriteLine("-> Drawing IDraw3D compatible type");
itf3d.Draw3D();
}
Далее вы можете проверить, поддерживает ли элемент в массиве
Shape
новый интерфейс, и если поддерживает, то передать его методу DrawIn3D()
на обработку:
Console.WriteLine("***** Fun with Interfaces *****\n");
Shape[] myShapes = { new Hexagon(), new Circle(),
new Triangle("Joe"), new Circle("JoJo") } ;
for(int i = 0; i < myShapes.Length; i++)
{
// Can I draw you in 3D?
if (myShapes[i] is IDraw3D s)
{
DrawIn3D(s);
}
}
Ниже представлен вывод, полученный из модифицированной версии приложения. Обратите внимание, что в трехмерном виде отображается только объект
Hexagon
, т.к. все остальные члены массива Shape
не реализуют интерфейс IDraw3D
:
***** Fun with Interfaces *****
...
-> Drawing IDraw3D compatible type
Drawing Hexagon in 3D!
Интерфейсы могут также применяться в качестве типов возвращаемых значений методов. Например, можно было бы написать метод, который получает массив объектов
Shape
и возвращает ссылку на первый элемент, поддерживающий IPointy
:
// Этот метод возвращает первый объект в массиве,
// который реализует интерфейс IPointy.
static IPointy FindFirstPointyShape(Shape[] shapes)
{
foreach (Shape s in shapes)
{
if (s is IPointy ip)
{
return ip;
}
}
return null;
}
Взаимодействовать с методом
FindFirstPointyShape()
можно так:
Console.WriteLine("***** Fun with Interfaces *****\n");
// Создать массив элементов Shape.
Shape[] myShapes = { new Hexagon(), new Circle(),
new Triangle("Joe"), new Circle("JoJo")};
// Получитгь первый элемент, имеющий вершины.
IPointy firstPointyItem = FindFirstPointyShape(myShapes);
// В целях безопасности использовать null-условную операцию.
Console.WriteLine("The item has {0} points",
firstPointyItem?.Points);
Вспомните, что один интерфейс может быть реализован множеством типов, даже если они не находятся внутри той же самой иерархии классов и не имеют общего родительского класса помимо
System.Object
. Это позволяет формировать очень мощные программные конструкции. Например, пусть в текущем проекте разработаны три новых класса: два класса (Knife
(нож) и Fork
(вилка)) моделируют кухонные приборы, а третий (PitchFork
(вилы)) — садовый инструмент. Ниже показан соответствующий код, а на рис. 8.3 — обновленная диаграмма классов.
// Fork.cs
namespace CustomInterfaces
{
class Fork : IPointy
{
public byte Points => 4;
}
}
// PitchFork.cs
namespace CustomInterfaces
{
class PitchFork : IPointy
{
public byte Points => 3;
}
}
// Knife.cs.cs
namespace CustomInterfaces
{
class Knife : IPointy
{
public byte Points => 1;
}
}
После определения типов
PitchFork
, Fork
и Knife
можно определить массив объектов, совместимых с IPointy
. Поскольку все элементы поддерживают один и тот же интерфейс, допускается выполнять проход по массиву и интерпретировать каждый его элемент как объект, совместимый с IPointy
, несмотря на разнородность иерархий классов:
...
// Этот массив может содержать только типы,
// которые реализуют интерфейс IPointy.
IPointy[] myPointyObjects = {new Hexagon(), new Knife(),
new Triangle(), new Fork(), new PitchFork()};
foreach(IPointy i in myPointyObjects)
{
Console.WriteLine("Object has {0} points.", i.Points);
}
Console.ReadLine();
Просто чтобы подчеркнуть важность продемонстрированного примера, запомните, что массив заданного интерфейсного типа может содержать элементы любых классов или структур, реализующих этот интерфейс.
Хотя программирование на основе интерфейсов является мощным приемом, реализация интерфейсов может быть сопряжена с довольно большим объемом клавиатурного ввода. Учитывая, что интерфейсы являются именованными наборами абстрактных членов, для каждого метода интерфейса в каждом типе, который поддерживает данное поведение, потребуется вводить определение и реализацию. Следовательно, если вы хотите поддерживать интерфейс, который определяет пять методов и три свойства, тогда придется принять во внимание все восемь членов (иначе возникнут ошибки на этапе компиляции).
К счастью, в Visual Studio и Visual Studio Code поддерживаются разнообразные инструменты, упрощающие задачу реализации интерфейсов. В качестве примера вставьте в текущий проект еще один класс по имени
PointyTestClass
. Когда вы добавите к типу класса интерфейс, такой как IPointy
(или любой другой подходящий интерфейс), то заметите, что по окончании ввода имени интерфейса (или при наведении на него курсора мыши в окне редактора кода) в Visual Studio и Visual Studio Code появляется значок с изображением лампочки (его также можно отобразить с помощью комбинации клавиш <Ctrl+.>). Щелчок на значке с изображением лампочки приводит к отображению раскрывающегося списка, который позволяет реализовать интерфейс (рис. 8.4 и 8.5).
Обратите внимание, что в списке предлагаются два пункта, из которых второй (явная реализация интерфейса) обсуждается в следующем разделе. Для начала выберите первый пункт. Среда Visual Studio/Visual Studio Code сгенерирует код заглушки, подлежащий обновлению (как видите, стандартная реализация генерирует исключение
System.NotImplementedException
, что вполне очевидно можно удалить):
namespace CustomInterfaces
{
class PointyTestClass : IPointy
{
public byte Points => throw new NotImplementedException();
}
}
На заметку! Среда Visual Studio/Visual Studio Code также поддерживает рефакторинг в форме извлечения интерфейса (Extract Interface), доступный через пункт Extract Interface (Извлечь интерфейс) меню Quick Actions (Быстрые действия). Такой рефакторинг позволяет извлечь новое определение интерфейса из существующего определения класса. Например, вы можете находиться где-то на полпути к завершению написания класса, но вдруг осознаете, что данное поведение можно обобщить в виде интерфейса (открывая возможность для альтернативных реализаций).
Как было показано ранее в главе, класс или структура может реализовывать любое количество интерфейсов. С учетом этого всегда существует возможность реализации интерфейсов, которые содержат члены с идентичными именами, из-за чего придется устранять конфликты имен. Чтобы проиллюстрировать разнообразные способы решения данной проблемы, создайте новый проект консольного приложения по имени
InterfaceNameClash
и добавьте в него три специальных интерфейса, представляющих различные места, в которых реализующий их тип может визуализировать свой вывод:
namespace InterfaceNameClash
{
// Вывести изображение на форму.
public interface IDrawToForm
{
void Draw();
}
}
namespace InterfaceNameClash
{
// Вывести изображение в буфер памяти.
public interface IDrawToMemory
{
void Draw();
}
}
namespace InterfaceNameClash
{
// Вывести изображение на принтер.
public interface IDrawToPrinter
{
void Draw();
}
}
Обратите внимание, что в каждом интерфейсе определен метод по имени
Draw()
с идентичной сигнатурой. Если все объявленные интерфейсы необходимо поддерживать в одном классе Octagon
, то компилятор разрешит следующее определение:
using System;
namespace InterfaceNameClash
{
class Octagon : IDrawToForm, IDrawToMemory, IDrawToPrinter
{
public void Draw()
{
// Разделяемая логика вывода.
Console.WriteLine("Drawing the Octagon...");
}
}
}
Хотя компиляция такого кода пройдет гладко, здесь присутствует потенциальная проблема. Выражаясь просто, предоставление единственной реализации метода
Draw()
не позволяет предпринимать уникальные действия на основе того, какой интерфейс получен от объекта Octagon
. Например, представленный ниже код будет приводить к вызову того же самого метода Draw()
независимо от того, какой интерфейс получен:
using System;
using InterfaceNameClash;
Console.WriteLine("***** Fun with Interface Name Clashes *****\n");
// Все эти обращения приводят к вызову одного
// и того же метода Draw()!
Octagon oct = new Octagon();
// Сокращенная форма записи, если переменная типа
// интерфейса в дальнейшем использоваться не будет.
((IDrawToPrinter)oct).Draw();
// Также можно применять ключевое слово is.
if (oct is IDrawToMemory dtm)
{
dtm.Draw();
}
Console.ReadLine();
Очевидно, что код, требуемый для визуализации изображения в окне, значительно отличается от кода, который необходим для вывода изображения на сетевой принтер или в область памяти. При реализации нескольких интерфейсов, имеющих идентичные члены, разрешить подобный конфликт имен можно с применением синтаксиса явной реализации интерфейсов. Взгляните на следующую модификацию типа
Octagon
:
class Octagon : IDrawToForm, IDrawToMemory, IDrawToPrinter
{
// Явно привязать реализации Draw() к конкретным интерфейсам.
void IDrawToForm.Draw()
{
Console.WriteLine("Drawing to form..."); // Вывод на форму
}
void IDrawToMemory.Draw()
{
Console.WriteLine("Drawing to memory..."); // Вывод в память
}
void IDrawToPrinter.Draw()
{
Console.WriteLine("Drawing to a printer..."); // Вывод на принтер
}
}
Как видите, при явной реализации члена интерфейса общий шаблон выглядит следующим образом:
возвращаемыйТип ИмяИнтерфейса.ИмяМетода(параметры) {}
Обратите внимание, что при использовании такого синтаксиса модификатор доступа не указывается: явно реализованные члены автоматически будут закрытыми. Например, такой синтаксис недопустим:
// Ошибка! Модификатор доступа не может быть указан!
public void IDrawToForm.Draw()
{
Console.WriteLine("Drawing to form...");
}
Поскольку явно реализованные члены всегда неявно закрыты, они перестают быть доступными на уровне объектов. Фактически, если вы примените к типу
Octagon
операцию точки, то обнаружите, что средство IntelliSense не отображает члены Draw()
. Как и следовало ожидать, для доступа к требуемой функциональности должно использоваться явное приведение. В предыдущих операторах верхнего уровня уже используется явное приведение, так что они работают с явными интерфейсами.
Console.WriteLine("***** Fun with Interface Name Clashes *****\n");
Octagon oct = new Octagon();
// Теперь для доступа к членам Draw() должно
// использоваться приведение.
IDrawToForm itfForm = (IDrawToForm)oct;
itfForm.Draw();
// Сокращенная форма записи, если переменная типа
// интерфейса в дальнейшем использоваться не будет.
((IDrawToPrinter)oct).Draw();
// Также можно применять ключевое слово is.
if (oct is IDrawToMemory dtm)
{
dtm.Draw();
}
Console.ReadLine();
Наряду с тем, что этот синтаксис действительно полезен, когда необходимо устранить конфликты имен, явную реализацию интерфейсов можно применять и просто для сокрытия более "сложных" членов на уровне объектов. В таком случае при использовании операции точки пользователь объекта будет видеть только подмножество всей функциональности типа. Однако когда требуется более сложное поведение, желаемый интерфейс можно извлекать через явное приведение.
Интерфейсы могут быть организованы в иерархии. Подобно иерархии классов, когда интерфейс расширяет существующий интерфейс, он наследует все абстрактные члены, определяемые родителем (или родителями). До выхода версии C# 8 производный интерфейс не наследовал действительную реализацию, а просто расширял собственное определение дополнительными абстрактными членами. В версии C# 8 производные интерфейсы наследуют стандартные реализации, а также расширяют свои определения и потенциально добавляют новые стандартные реализации.
Иерархии интерфейсов могут быть удобными, когда нужно расширить функциональность имеющегося интерфейса, не нарушая работу существующих кодовых баз. В целях иллюстрации создайте новый проект консольного приложения по имени
InterfaceHierarchy
. Затем спроектируйте новый набор интерфейсов, связанных с визуализацией, таким образом, чтобы IDrawable
был корневым интерфейсом в дереве семейства:
namespace InterfaceHierarchy
{
public interface IDrawable
{
void Draw();
}
}
С учетом того, что интерфейс
IDrawable
определяет базовое поведение рисования, можно создать производный интерфейс, который расширяет IDrawable
возможностью визуализации в других форматах, например:
namespace InterfaceHierarchy
{
public interface IAdvancedDraw : IDrawable
{
void DrawInBoundingBox(int top, int left, int bottom, int right);
void DrawUpsideDown();
}
}
При таком проектном решении, если класс реализует интерфейс
IAdvancedDraw
, тогда ему потребуется реализовать все члены, определенные в цепочке наследования (в частности методы Draw()
, DrawInBoundingBox()
и DrawUpsideDown()
):
using System;
namespace InterfaceHierarchy
{
public class BitmapImage : IAdvancedDraw
{
public void Draw()
{
Console.WriteLine("Drawing...");
}
public void DrawInBoundingBox(int top, int left, int bottom, int right)
{
Console.WriteLine("Drawing in a box...");
}
public void DrawUpsideDown()
{
Console.WriteLine("Drawing upside down!");
}
}
}
Теперь в случае применения класса
BitmapImage
появилась возможность вызывать каждый метод на уровне объекта (из-за того, что все они открыты), а также извлекать ссылку на каждый поддерживаемый интерфейс явным образом через приведение:
using System;
using InterfaceHierarchy;
Console.WriteLine("***** Simple Interface Hierarchy *****");
// Вызвать на уровне объекта.
BitmapImage myBitmap = new BitmapImage();
myBitmap.Draw();
myBitmap.DrawInBoundingBox(10, 10, 100, 150);
myBitmap.DrawUpsideDown();
// Получить IAdvancedDraw явным образом.
if (myBitmap is IAdvancedDraw iAdvDraw)
{
iAdvDraw.DrawUpsideDown();
}
Console.ReadLine();
Когда иерархии интерфейсов также включают стандартные реализации, то нижерасположенные интерфейсы могут задействовать реализацию из базового интерфейса или создать новую стандартную реализацию. Модифицируйте интерфейс
IDrawable
, как показано ниже:
public interface IDrawable
{
void Draw();
int TimeToDraw() => 5;
}
Теперь обновите операторы верхнего уровня:
Console.WriteLine("***** Simple Interface Hierarchy *****");
...
if (myBitmap is IAdvancedDraw iAdvDraw)
{
iAdvDraw.DrawUpsideDown();
Console.WriteLine($"Time to draw: {iAdvDraw.TimeToDraw()}");
}
Console.ReadLine();
Этот код не только скомпилируется, но и выведет значение
5
при вызове метода TimeToDraw()
. Дело в том, что стандартные реализации попадают в производные интерфейсы автоматически. Приведение BitMapImage
к интерфейсу IAdvancedDraw
обеспечивает доступ к методу TimeToDraw()
, хотя экземпляр BitMapImage
не имеет доступа к стандартной реализации. Чтобы удостовериться в этом, введите следующий код, который вызовет ошибку на этапе компиляции:
// Этот код не скомпилируется
myBitmap.TimeToDraw();
Если в нижерасположенном интерфейсе желательно предоставить собственную стандартную реализацию, тогда потребуется скрыть вышерасположенную реализацию. Например, если вычерчивание в методе
TimeToDraw()
из IAdvancedDraw
занимает 15 единиц времени, то модифицируйте определение интерфейса следующим образом:
public interface IAdvancedDraw : IDrawable
{
void DrawInBoundingBox(
int top, int left, int bottom, int right);
void DrawUpsideDown();
new int TimeToDraw() => 15;
}
Разумеется, в классе
BitMapImage
также можно реализовать метод TimeToDraw()
. В отличие от метода TimeToDraw()
из IAdvancedDraw
в классе необходимо только реализовать метод без его сокрытия.
public class BitmapImage : IAdvancedDraw
{
...
public int TimeToDraw() => 12;
}
В случае приведения экземпляра
BitmapImage
к интерфейсу IAdvancedDraw
или IDrawable
метод на экземпляре по-прежнему выполняется. Добавьте к операторам верхнего уровня показанный далее код:
// Всегда вызывается метод на экземпляре:
Console.WriteLine("***** Calling Implemented TimeToDraw *****");
Console.WriteLine($"Time to draw: {myBitmap.TimeToDraw()}");
Console.WriteLine($"Time to draw: {((IDrawable) myBitmap).TimeToDraw()}");
Console.WriteLine($"Time to draw: {((IAdvancedDraw) myBitmap).TimeToDraw()}");
Вот результаты:
***** Simple Interface Hierarchy *****
...
***** Calling Implemented TimeToDraw *****
Time to draw: 12
Time to draw: 12
Time to draw: 12
В отличие от типов классов интерфейс может расширять множество базовых интерфейсов, что позволяет проектировать мощные и гибкие абстракции. Создайте новый проект консольного приложения по имени
MiInterfaceHierarchy
. Здесь имеется еще одна коллекция интерфейсов, которые моделируют разнообразные абстракции, связанные с визуализацией и фигурами. Обратите внимание, что интерфейс IShape
расширяет и IDrawable
, и IPrintable
:
// IDrawable.cs
namespace MiInterfaceHierarchy
{
// Множественное наследование для интерфейсных типов - это нормально.
interface IDrawable
{
void Draw();
}
}
// IPrintable.cs
namespace MiInterfaceHierarchy
{
interface IPrintable
{
void Print();
void Draw(); // < -- Здесь возможен конфликт имен!
}
}
// IShape.cs
namespace MiInterfaceHierarchy
{
// Множественное наследование интерфейсов. Нормально!
interface IShape : IDrawable, IPrintable
{
int GetNumberOfSides();
}
}
На рис. 8.6 показана текущая иерархия интерфейсов.
Главный вопрос: сколько методов должен реализовывать класс, поддерживающий
IShape
? Ответ: в зависимости от обстоятельств. Если вы хотите предоставить простую реализацию метода Draw()
, тогда вам необходимо реализовать только три члена, как иллюстрируется в следующем типе Rectangle
:
using System;
namespace MiInterfaceHierarchy
{
class Rectangle : IShape
{
public int GetNumberOfSides() => 4;
public void Draw() => Console.WriteLine("Drawing...");
public void Print() => Console.WriteLine("Printing...");
}
}
Если вы предпочитаете располагать специфическими реализациями для каждого метода
Draw()
(что в данном случае имеет смысл), то конфликт имен можно устранить с использованием явной реализации интерфейсов, как делается в представленном далее типе Square
:
namespace MiInterfaceHierarchy
{
class Square : IShape
{
// Использование явной реализации для устранения
// конфликта имен членов.
void IPrintable.Draw()
{
// Вывести на принтер...
}
void IDrawable.Draw()
{
// Вывести на экран...
}
public void Print()
{
// Печатать...
}
public int GetNumberOfSides() => 4;
}
}
В идеале к данному моменту вы должны лучше понимать процесс определения и реализации специальных интерфейсов с применением синтаксиса С#. По правде говоря, привыкание к программированию на основе интерфейсов может занять определенное время, так что если вы находитесь в некотором замешательстве, то это совершенно нормальная реакция.
Однако имейте в виду, что интерфейсы являются фундаментальным аспектом .NET Core. Независимо от типа разрабатываемого приложения (веб-приложение, настольное приложение с графическим пользовательским интерфейсом, библиотека доступа к данным и т.п.) работа с интерфейсами будет составной частью этого процесса. Подводя итог, запомните, что интерфейсы могут быть исключительно полезны в следующих ситуациях:
• существует единственная иерархия, в которой общее поведение поддерживается только подмножеством производных типов;
• необходимо моделировать общее поведение, которое встречается в нескольких иерархиях, не имеющих общего родительского класса кроме
System.Object
.
Итак, вы ознакомились со спецификой построения и реализации специальных интерфейсов. Остаток главы посвящен исследованию нескольких предопределенных интерфейсов, содержащихся в библиотеках базовых классов .NET Core. Как будет показано, вы можете реализовывать стандартные интерфейсы .NET Core в своих специальных типах, обеспечивая их бесшовную интеграцию с инфраструктурой.
Прежде чем приступать к исследованию процесса реализации существующих интерфейсов .NET Core, давайте сначала рассмотрим роль интерфейсов
IEnumerable
и IEnumerator
. Вспомните, что язык C# поддерживает ключевое слово foreach
, которое позволяет осуществлять проход по содержимому массива любого типа:
// Итерация по массиву элементов.
int[] myArrayOfInts = {10, 20, 30, 40};
foreach(int i in myArrayOfInts)
{
Console.WriteLine(i);
}
Хотя может показаться, что данная конструкция подходит только для массивов, на самом деле
foreach
разрешено использовать с любым типом, который поддерживает метод GetEnumerator()
. В целях иллюстрации создайте новый проект консольного приложения по имени CustomEnumerator
. Скопируйте в новый проект файлы Car.cs
и Radio.cs
из проекта SimpleException
, рассмотренного в главе 7. Не забудьте поменять пространства имен для классов на CustomEnumerator
.
Теперь вставьте в проект новый класс
Garage
(гараж), который хранит набор объектов Car
(автомобиль) внутри System.Array
:
using System.Collections;
namespace CustomEnumerator
{
// Garage содержит набор объектов Car.
public class Garage
{
private Car[] carArray = new Car[4];
// При запуске заполнить несколькими объектами Car.
public Garage()
{
carArray[0] = new Car("Rusty", 30);
carArray[1] = new Car("Clunker", 55);
carArray[2] = new Car("Zippy", 30);
carArray[3] = new Car("Fred", 30);
}
}
}
В идеальном случае было бы удобно проходить по внутренним элементам объекта
Garage
с применением конструкции foreach
как в ситуации с массивом значений данных:
using System;
using CustomEnumerator;
// Код выглядит корректным...
Console.WriteLine("***** Fun with IEnumerable / IEnumerator *****\n");
Garage carLot = new Garage();
// Проход по всем объектам Car в коллекции?
foreach (Car c in carLot)
{
Console.WriteLine("{0} is going {1} MPH",
c.PetName, c.CurrentSpeed);
}
Console.ReadLine();
К сожалению, компилятор информирует о том, что в классе Garage не реализован метод по имени
GetEnumerator()
, который формально определен в интерфейсе IEnumerable
, находящемся в пространстве имен System.Collections
.
На заметку! В главе 10 вы узнаете о роли обобщений и о пространстве имен
System.Collections.Generic
. Как будет показано, это пространство имен содержит обобщенные версии интерфейсов IEnumerable/IEnumerator
, которые предлагают более безопасный к типам способ итерации по элементам.
Классы или структуры, которые поддерживают такое поведение, позиционируются как способные предоставлять вызывающему коду доступ к элементам, содержащимся внутри них (в рассматриваемом примере самому ключевому слову
foreach
). Вот определение этого стандартного интерфейса:
// Данный интерфейс информирует вызывающий код о том,
// что элементы объекта могут перечисляться
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
Как видите, метод
GetEnumerator()
возвращает ссылку на еще один интерфейс по имени System.Collections.IEnumerator
, обеспечивающий инфраструктуру, которая позволяет вызывающему коду обходить внутренние объекты, содержащиеся в совместимом с IEnumerable
контейнере:
// Этот интерфейс позволяет вызывающему коду получать элементы контейнера.
public interface IEnumerator
{
bool MoveNext (); // Переместить вперед внутреннюю позицию курсора.
object Current { get;} // Получить текущий элемент
// (свойство только для чтения).
void Reset (); // Сбросить курсор в позицию перед первым элементом.
}
Если вы хотите обновить тип
Garage
для поддержки этих интерфейсов, то можете пойти длинным путем и реализовать каждый метод вручную. Хотя вы определенно вольны предоставить специализированные версии методов GetEnumerator()
, MoveNext()
, Current
и Reset()
, существует более легкий путь. Поскольку тип System.Array
(а также многие другие классы коллекций) уже реализует интерфейсы IEnumerable
и IEnumerator
, вы можете просто делегировать запрос к System.Array
следующим образом (обратите внимание, что в файл кода понадобится импортировать пространство имен System.Collections
):
using System.Collections;
...
public class Garage : IEnumerable
{
// System.Array уже реализует IEnumerator!
private Car[] carArray = new Car[4];
public Garage()
{
carArray[0] = new Car("FeeFee", 200);
carArray[1] = new Car("Clunker", 90);
carArray[2] = new Car("Zippy", 30);
carArray[3] = new Car("Fred", 30);
}
// Возвратить IEnumerator объекта массива.
public IEnumerator GetEnumerator()
=> carArray.GetEnumerator();
}
После такого изменения тип
Garage
можно безопасно использовать внутри конструкции foreach
. Более того, учитывая, что метод GetEnumerator()
был определен как открытый, пользователь объекта может также взаимодействовать с типом IEnumerator
:
// Вручную работать с IEnumerator.
IEnumerator carEnumerator = carLot.GetEnumerator();
carEnumerator.MoveNext();
Car myCar = (Car)i.Current;
Console.WriteLine("{0} is going {1} MPH", myCar.PetName, myCar.CurrentSpeed);
Тем не менее, если вы предпочитаете скрыть функциональность
IEnumerable
на уровне объектов, то просто задействуйте явную реализацию интерфейса:
// Возвратить IEnumerator объекта массива.
IEnumerator IEnumerable.GetEnumerator()
=> return carArray.GetEnumerator();
В результате обычный пользователь объекта не обнаружит метод
GetEnumerator()
в классе Garage
, в то время как конструкция foreach
при необходимости будет получать интерфейс в фоновом режиме.
Существует альтернативный способ построения типов, которые работают с циклом
foreach
, предусматривающий использование итераторов. Попросту говоря, итератор — это член, который указывает, каким образом должны возвращаться внутренние элементы контейнера во время обработки в цикле foreach
. В целях иллюстрации создайте новый проект консольного приложения по имени CustomEnumeratorWithYield
и вставьте в него типы Car
, Radio
и Garage
из предыдущего примера (снова переименовав пространство имен согласно текущему проекту). Затем модифицируйте тип Garage
:
public class Garage : IEnumerable
{
...
// Итераторный метод.
public IEnumerator GetEnumerator()
{
foreach (Car c in carArray)
{
yield return c;
}
}
}
Обратите внимание, что показанная реализация метода
GetEnumerator()
осуществляет проход по элементам с применением внутренней логики foreach
и возвращает каждый объект Car
вызывающему коду, используя синтаксис yield return
. Ключевое слово yield
применяется для указания значения или значений, которые подлежат возвращению конструкцией foreach
вызывающему коду. При достижении оператора yield return
текущее местоположение в контейнере сохраняется и выполнение возобновляется с этого местоположения, когда итератор вызывается в следующий раз.
Итераторные методы не обязаны использовать ключевое слово
foreach
для возвращения своего содержимого. Итераторный метод допускается определять и так:
public IEnumerator GetEnumerator()
{
yield return carArray[0];
yield return carArray[1];
yield return carArray[2];
yield return carArray[3];
}
В этой реализации обратите внимание на то, что при каждом своем прохождении метод
GetEnumerator()
явно возвращает вызывающему коду новое значение. В рассматриваемом примере поступать подобным образом мало смысла, потому что если вы добавите дополнительные объекты к переменной-члену carArray
, то метод GetEnumerator()
станет рассогласованным. Тем не менее, такой синтаксис может быть полезен, когда вы хотите возвращать из метода локальные данные для обработки посредством foreach
.
До первого прохода по элементам (или доступа к любому элементу) никакой код в методе
GetEnumerator()
не выполняется. Таким образом, если до выполнения оператора yield
возникает условие для исключения, то оно не будет сгенерировано при первом вызове метода, а лишь во время первого вызова MoveNext()
.
Чтобы проверить это, модифицируйте
GetEnumerator()
:
public IEnumerator GetEnumerator()
{
// Исключение не сгенерируется до тех пор, пока не будет вызван
// метод MoveNext().
throw new Exception("This won't get called");
foreach (Car c in carArray)
{
yield return c;
}
}
Если функция вызывается, как показано далее, и больше ничего не делается, тогда исключение никогда не сгенерируется:
using System.Collections;
...
Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();
IEnumerator carEnumerator = carLot.GetEnumerator();
Console.ReadLine();
Код выполнится только после вызова
MoveNext()
и сгенерируется исключение. В зависимости от нужд программы это может быть как вполне нормально, так и нет. Ваш метод GetEnumerator()
может иметь защитную конструкцию, которую необходимо выполнить при вызове метода в первый раз. В качестве примера предположим, что список формируется из базы данных. Вам может понадобиться организовать проверку, открыто ли подключение к базе данных, во время вызова метода, а не при проходе по списку. Или же может возникнуть потребность в проверке достоверности входных параметров метода Iterator()
, который рассматривается далее.
Вспомните средство локальных функций версии C# 7, представленное в главе 4; локальные функции — это закрытые функции, которые определены внутри других функций. За счет перемещения
yield return
внутрь локальной функции, которая возвращается из главного тела метода, операторы верхнего уровня (до возвращения локальной функции) выполняются немедленно. Локальная функция выполняется при вызове MoveNext()
.
Приведите метод к следующему виду:
public IEnumerator GetEnumerator()
{
// Это исключение сгенерируется немедленно
throw new Exception("This will get called");
return ActualImplementation();
// Локальная функция и фактическая реализация IEnumerator
IEnumerator ActualImplementation()
{
foreach (Car c in carArray)
{
yield return c;
}
}
}
Ниже показан тестовый код:
Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();
try
{
// На этот раз возникает ошибка
var carEnumerator = carLot.GetEnumerator();
}
catch (Exception e)
{
Console.WriteLine($"Exception occurred on GetEnumerator");
}
Console.ReadLine();
В результате такого обновления метода
GetEnumerator()
исключение генерируется незамедлительно, а не при вызове MoveNext()
.
Также интересно отметить, что ключевое слово
yield
формально может применяться внутри любого метода независимо от его имени. Такие методы (которые официально называются именованными итераторами) уникальны тем, что способны принимать любое количество аргументов. При построении именованного итератора имейте в виду, что метод будет возвращать интерфейс IEnumerable
, а не ожидаемый совместимый с IEnumerator
тип. В целях иллюстрации добавьте к типу Garage
следующий метод (использующий локальную функцию для инкапсуляции функциональности итерации):
public IEnumerable GetTheCars(bool returnReversed)
{
// Выполнить проверку на предмет ошибок
return ActualImplementation();
IEnumerable ActualImplementation()
{
// Возвратить элементы в обратном порядке.
if (returnReversed)
{
for (int i = carArray.Length; i != 0; i--)
{
yield return carArray[i - 1];
}
}
else
{
// Возвратить элементы в том порядке, в каком они размещены в массиве.
foreach (Car c in carArray)
{
yield return c;
}
}
}
}
Обратите внимание, что новый метод позволяет вызывающему коду получать элементы в прямом, а также в обратном порядке, если во входном параметре указано значение
true
. Теперь взаимодействовать с методом GetTheCars()
можно так (обязательно закомментируйте оператор throw new
в методе GetEnumerator()
):
Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();
// Получить элементы, используя GetEnumerator().
foreach (Car c in carLot)
{
Console.WriteLine("{0} is going {1} MPH",
c.PetName, c.CurrentSpeed);
}
Console.WriteLine();
// Получить элементы (в обратном порядке!)
// с применением именованного итератора.
foreach (Car c in carLot.GetTheCars(true))
{
Console.WriteLine("{0} is going {1} MPH",
c.PetName, c.CurrentSpeed);
}
Console.ReadLine();
Наверняка вы согласитесь с тем, что именованные итераторы являются удобными конструкциями, поскольку они позволяют определять в единственном специальном контейнере множество способов запрашивания возвращаемого набора.
Итак, в завершение темы построения перечислимых объектов запомните: для того, чтобы специальные типы могли работать с ключевым словом
foreach
языка С#, контейнер должен определять метод по имени GetEnumerator()
, который формально определен интерфейсным типом IEnumerable
. Этот метод обычно реализуется просто за счет делегирования работы внутреннему члену, который хранит подобъекты, но допускается также использовать синтаксис yield return
, чтобы предоставить множество методов "именованных итераторов".
Вспомните из главы 6, что в классе
System.Object
определен метод по имени MemberwiseClone()
, который применяется для получения поверхностной (неглубокой) копии текущего объекта. Пользователи объекта не могут вызывать указанный метод напрямую, т.к. он является защищенным. Тем не менее, отдельный объект может самостоятельно вызывать MemberwiseClone()
во время процесса клонирования. В качестве примера создайте новый проект консольного приложения по имени CloneablePoint
, в котором определен класс Point
:
using System;
namespace CloneablePoint
{
// Класс по имени Point.
public class Point
{
public int X {get; set;}
public int Y {get; set;}
public Point(int xPos, int yPos) { X = xPos; Y = yPos;}
public Point(){}
// Переопределить Object.ToString().
public override string ToString() => $"X = {X}; Y = {Y}";
}
}
Учитывая имеющиеся у вас знания о ссылочных типах и типах значений (см.главу 4), должно быть понятно, что если вы присвоите одну переменную ссылочного типа другой такой переменной, то получите две ссылки, которые указывают на тот же самый объект в памяти. Таким образом, следующая операция присваивания в результате дает две ссылки на один и тот же объект
Point
в куче; модификация с использованием любой из ссылок оказывает воздействие на тот же самый объект в куче:
Console.WriteLine("***** Fun with Object Cloning *****\n");
// Две ссылки на один и тот же объект!
Point p1 = new Point(50, 50);
Point p2 = p1;
p2.X = 0;
Console.WriteLine(p1);
Console.WriteLine(p2);
Console.ReadLine();
Чтобы предоставить специальному типу возможность возвращения вызывающему коду идентичную копию самого себя, можно реализовать стандартный интерфейс
ICloneable
. Как было показано в начале главы, в интерфейсе ICloneable
определен единственный метод по имени Clone()
:
public interface ICloneable
{
object Clone();
}
Очевидно, что реализация метода
Clone()
варьируется от класса к классу. Однако базовая функциональность в основном остается неизменной: копирование значений переменных-членов в новый объект того же самого типа и возвращение его пользователю. В целях демонстрации модифицируйте класс Point
:
// Теперь Point поддерживает способность клонирования.
public class Point : ICloneable
{
public int X { get; set; }
public int Y { get; set; }
public Point(int xPos, int yPos) { X = xPos; Y = yPos; }
public Point() { }
// Переопределить Object.ToString().
public override string ToString() => $"X = {X}; Y = {Y}";
// Возвратить копию текущего объекта.
public object Clone() => new Point(this.X, this.Y);
}
Теперь можно создавать точные автономные копии объектов типа
Point
:
Console.WriteLine("***** Fun with Object Cloning *****\n");
...
// Обратите внимание, что Clone() возвращает простой тип object
.
// Для получения производного типа требуется явное приведение
Point p3 = new Point(100, 100);
Point p4 = (Point)p3.Clone();
// Изменить р4.Х (что не приводит к изменению р3.Х).
p4.X = 0;
// Вывести все объекты.
Console.WriteLine(p3);
Console.WriteLine(p4);
Console.ReadLine();
Несмотря на то что текущая реализация типа
Point
удовлетворяет всем требованиям, есть возможность ее немного улучшить. Поскольку Point
не содержит никаких внутренних переменных ссылочного типа, реализацию метода Clone()
можно упростить:
// Копировать все поля Point по очереди.
public object Clone() => this.MemberwiseClone();
Тем не менее, учтите, что если бы в типе
Point
содержались любые переменные-члены ссылочного типа, то метод MemberwiseClone()
копировал бы ссылки на эти объекты (т.е. создавал бы поверхностную копию). Для поддержки подлинной глубокой (детальной) копии во время процесса клонирования понадобится создавать новые экземпляры каждой переменной-члена ссылочного типа. Давайте рассмотрим пример.
Теперь предположим, что класс
Point
содержит переменную-член ссылочного типа PointDescription
. Данный класс представляет дружественное имя точки, а также ее идентификационный номер, выраженный как System.Guid
(глобально уникальный идентификатор (globally unique identifier — GUID), т.е. статистически уникальное 128-битное число). Вот как выглядит реализация:
using System;
namespace CloneablePoint
{
// Этот класс описывает точку.
public class PointDescription
{
public string PetName {get; set;}
public Guid PointID {get; set;}
public PointDescription()
{
PetName = "No-name";
PointID = Guid.NewGuid();
}
}
}
Начальные изменения самого класса
Point
включают модификацию метода ToString()
для учета новых данных состояния, а также определение и создание ссылочного типа PointDescription
. Чтобы позволить внешнему миру устанавливать дружественное имя для Point
, необходимо также изменить аргументы, передаваемые перегруженному конструктору:
public class Point : ICloneable
{
public int X { get; set; }
public int Y { get; set; }
public PointDescription desc = new PointDescription();
public Point(int xPos, int yPos, string petName)
{
X = xPos; Y = yPos;
desc.PetName = petName;
}
public Point(int xPos, int yPos)
{
X = xPos; Y = yPos;
}
public Point() { }
// Переопределить Object.ToString().
public override string ToString()
=> $"X = {X}; Y = {Y}; Name = {desc.PetName};\nID = {desc.PointID}\n";
// Возвратить копию текущего объекта.
public object Clone() => this.MemberwiseClone();
}
Обратите внимание, что метод
Clone()
пока еще не обновлялся. Следовательно, когда пользователь объекта запросит клонирование с применением текущей реализации, будет создана поверхностная (почленная) копия. В целях иллюстрации модифицируйте вызывающий код, как показано ниже:
Console.WriteLine("***** Fun with Object Cloning *****\n");
...
Console.WriteLine("Cloned p3 and stored new Point in p4");
Point p3 = new Point(100, 100, "Jane");
Point p4 = (Point)p3.Clone();
Console.WriteLine("Before modification:"); // Перед модификацией
Console.WriteLine("p3: {0}", p3);
Console.WriteLine("p4: {0}", p4);
p4.desc.PetName = "My new Point";
p4.X = 9;
Console.WriteLine("\nChanged p4.desc.petName and p4.X");
Console.WriteLine("After modification:"); // После модификации
Console.WriteLine("p3: {0}", p3);
Console.WriteLine("p4: {0}", p4);
Console.ReadLine();
В приведенном далее выводе видно, что хотя типы значений действительно были изменены, внутренние ссылочные типы поддерживают одни и те же значения, т.к. они "указывают" на те же самые объекты в памяти (в частности, оба объекта имеют дружественное имя
Му new Point
):
***** Fun with Object Cloning *****
Cloned p3 and stored new Point in p4
Before modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509
p4: X = 100; Y = 100; Name = Jane;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509
Changed p4.desc.petName and p4.X
After modification:
p3: X = 100; Y = 100; Name = My new Point;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509
p4: X = 9; Y = 100; Name = My new Point;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509
Чтобы заставить метод
Clone()
создавать полную глубокую копию внутренних ссылочных типов, нужно сконфигурировать объект, возвращаемый методом MemberwiseClone()
, для учета имени текущего объекта Point
(тип System.Guid
на самом деле является структурой, так что числовые данные будут действительно копироваться). Вот одна из возможных реализаций:
// Теперь необходимо скорректировать код для учета члена.
public object Clone()
{
// Сначала получить поверхностную копию.
Point newPoint = (Point)this.MemberwiseClone();
// Затем восполнить пробелы.
PointDescription currentDesc = new PointDescription();
currentDesc.PetName = this.desc.PetName;
newPoint.desc = currentDesc;
return newPoint;
}
Если снова запустить приложение и просмотреть его вывод (показанный далее), то будет видно, что возвращаемый методом
Clone()
объект Point
действительно копирует свои внутренние переменные-члены ссылочного типа (обратите внимание, что дружественные имена у рЗ
и р4
теперь уникальны):
***** Fun with Object Cloning *****
Cloned p3 and stored new Point in p4
Before modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 51f64f25-4b0e-47ac-ba35-37d263496406
p4: X = 100; Y = 100; Name = Jane;
ID = 0d3776b3-b159-490d-b022-7f3f60788e8a
Changed p4.desc.petName and p4.X
After modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 51f64f25-4b0e-47ac-ba35-37d263496406
p4: X = 9; Y = 100; Name = My new Point;
ID = 0d3776b3-b159-490d-b022-7f3f60788e8a
Давайте подведем итоги по процессу клонирования. При наличии класса или структуры, которая содержит только типы значений, необходимо реализовать метод
Clone()
с использованием метода MemberwiseClone()
. Однако если есть специальный тип, поддерживающий ссылочные типы, тогда для построения глубокой копии может потребоваться создать новый объект, который учитывает каждую переменную-член ссылочного типа.
Интерфейс
System.IComparable
описывает поведение, которое позволяет сортировать объекты на основе указанного ключа. Вот его формальное определение:
// Данный интерфейс позволяет объекту указывать
// его отношение с другими подобными объектами
public interface IComparable
{
int CompareTo(object o);
}
На заметку! Обобщенная версия этого интерфейса (
IСоmраrаble<Т>
) предлагает более безопасный в отношении типов способ обработки операций сравнения объектов. Обобщения исследуются в главе 10.
Создайте новый проект консольного приложения по имени
ComparableCar
, скопируйте классы Car
и Radio
из проекта SimpleException
, рассмотренного в главе 7, и поменяйте пространство имен в каждом файле класса на ComparableCar
. Обновите класс Car
, добавив новое свойство для представления уникального идентификатора каждого автомобиля и модифицированный конструктор:
using System;
using System.Collections;
namespace ComparableCar
{
public class Car
{
...
public int CarID {get; set;}
public Car(string name, int currSp, int id)
{
CurrentSpeed = currSp;
PetName = name;
CarID = id;
}
...
}
}
Теперь предположим, что имеется следующий массив объектов
Car
:
using System;
using ComparableCar;
Console.WriteLine("***** Fun with Object Sorting *****\n");
// Создать массив объектов Car.
Car[] myAutos = new Car[5];
myAutos[0] = new Car("Rusty", 80, 1);
myAutos[1] = new Car("Mary", 40, 234);
myAutos[2] = new Car("Viper", 40, 34);
myAutos[3] = new Car("Mel", 40, 4);
myAutos[4] = new Car("Chucky", 40, 5);
Console.ReadLine();
В классе
System.Array
определен статический метод по имени Sort()
. Его вызов для массива внутренних типов (int
, short
, string
и т.д.) приводит к сортировке элементов массива в числовом или алфавитном порядке, т.к. внутренние типы данных реализуют интерфейс IComparable
. Но что произойдет, если передать методу Sort()
массив объектов Car
?
// Сортируются ли объекты Car? Пока еще нет!
Array.Sort(myAutos);
Запустив тестовый код, вы получите исключение времени выполнения, потому что класс
Car
не поддерживает необходимый интерфейс. При построении специальных типов вы можете реализовать интерфейс IComparable
, чтобы позволить массивам, содержащим элементы этих типов, подвергаться сортировке. Когда вы реализуете детали СоmраrеТо()
, то должны самостоятельно принять решение о том, что должно браться за основу в операции упорядочивания. Для типа Car
вполне логичным кандидатом может служить внутреннее свойство CarID
:
// Итерация по объектам Car может быть упорядочена на основе CarID.
public class Car : IComparable
{
...
// Реализация интерфейса IComparable.
int IComparable.CompareTo(object obj)
{
if (obj is Car temp)
{
if (this.CarID > temp.CarID)
{
return 1;
}
if (this.CarID < temp.CarID)
{
return -1;
}
return 0;
}
throw new ArgumentException("Parameter is not a Car!");
// Параметр не является объектом типа Car!
}
}
Как видите, логика метода
CompareTo()
заключается в сравнении входного объекта с текущим экземпляром на основе специфичного элемента данных. Возвращаемое значение метода CompareTo()
применяется для выяснения того, является текущий объект меньше, больше или равным объекту, с которым он сравнивается (табл. 8.1).
Предыдущую реализацию метода
CompareTo()
можно усовершенствовать с учетом того факта, что тип данных int
в C# (который представляет собой просто сокращенное обозначение для типа System.Int32
) реализует интерфейс IComparable
. Реализовать CompareTo()
в Car
можно было бы так:
int IComparable.CompareTo(object obj)
{
if (obj is Car temp)
{
return this.CarID.CompareTo(temp.CarID);
}
throw new ArgumentException("Parameter is not a Car!");
// Параметр не является объектом типа Car!
}
В любом случае, поскольку тип
Car
понимает, как сравнивать себя с подобными объектами, вы можете написать следующий тестовый код:
// Использование интерфейса IComparable.
// Создать массив объектов Car.
...
// Отобразить текущее содержимое массива.
Console.WriteLine("Here is the unordered set of cars:");
foreach(Car c in myAutos)
{
Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}
// Теперь отсортировать массив с применением IComparable!
Array.Sort(myAutos);
Console.WriteLine();
// Отобразить отсортированное содержимое массива.
Console.WriteLine("Here is the ordered set of cars:");
foreach(Car c in myAutos)
{
Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}
Console.ReadLine();
Ниже показан вывод, полученный в результате выполнения приведенного выше кода:
***** Fun with Object Sorting *****
Here is the unordered set of cars:
1 Rusty
234 Mary
34 Viper
4 Mel
5 Chucky
Here is the ordered set of cars:
1 Rusty
4 Mel
5 Chucky
34 Viper
234 Mary
В текущей версии класса
Car
в качестве основы для порядка сортировки используется идентификатор автомобиля (CarID
). В другом проектном решении основой сортировки могло быть дружественное имя автомобиля (для вывода списка автомобилей в алфавитном порядке). А что если вы хотите построить класс Car
, который можно было бы подвергать сортировке по идентификатору и также по дружественному имени? В таком случае вы должны ознакомиться с еще одним стандартным интерфейсом по имени IComparer
, который определен в пространстве имен System.Collections
следующим образом:
// Общий способ сравнения двух объектов.
interface IComparer
{
int Compare(object o1, object o2);
}
На заметку! Обобщенная версия этого интерфейса (
IСоmраrаble<Т>
) обеспечивает более безопасный в отношении типов способ обработки операций сравнения объектов. Обобщения подробно рассматриваются в главе 10.
В отличие от
IСоmраrаble
интерфейс IComparer
обычно не реализуется в типе, который вы пытаетесь сортировать (т.е. Car
). Взамен данный интерфейс реализуется в любом количестве вспомогательных классов, по одному для каждого порядка сортировки (на основе дружественного имени, идентификатора автомобиля и т.д.). В настоящий момент типу Car
уже известно, как сравнивать автомобили друг с другом по внутреннему идентификатору. Следовательно, чтобы позволить пользователю объекта сортировать массив объектов Car
по дружественному имени, потребуется создать дополнительный вспомогательный класс, реализующий интерфейс IComparer
. Вот необходимый код (не забудьте импортировать в файл кода пространство имен System.Collections
):
using System;
using System.Collections;
namespace ComparableCar
{
// Этот вспомогательный класс используется для сортировки
// массива объектов Car по дружественному имени.
public class PetNameComparer : IComparer
{
// Проверить дружественное имя каждого объекта.
int IComparer.Compare(object o1, object o2)
{
if (o1 is Car t1 && o2 is Car t2)
{
return string.Compare(t1.PetName, t2.PetName,
StringComparison.OrdinalIgnoreCase);
}
else
{
throw new ArgumentException("Parameter is not a Car!");
// Параметр не является объектом типа Car!
}
}
}
}
Вспомогательный класс
PetNameComparer
может быть задействован в коде. Класс System.Array
содержит несколько перегруженных версий метода Sort()
, одна из которых принимает объект, реализующий интерфейс IComparer
:
...
// Теперь сортировать по дружественному имени.
Array.Sort(myAutos, new PetNameComparer());
// Вывести отсортированный массив.
Console.WriteLine("Ordering by pet name:");
foreach(Car c in myAutos)
{
Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}
...
Важно отметить, что вы можете применять специальное статическое свойство, оказывая пользователю объекта помощь с сортировкой типов
Car
по специфичному элементу данных. Предположим, что в класс Car
добавлено статическое свойство только для чтения по имени SortByPetName
, которое возвращает экземпляр класса, реализующего интерфейс IComparer
(в этом случае PetNameComparer
; не забудьте импортировать пространство имен System.Collections
):
// Теперь мы поддерживаем специальное свойство для возвращения
// корректного экземпляра, реализующего интерфейс IComparer.
public class Car : IComparable
{
...
// Свойство, возвращающее PetNameComparer.
public static IComparer SortByPetName
=> (IComparer)new PetNameComparer();}
Теперь в коде массив можно сортировать по дружественному имени, используя жестко ассоциированное свойство, а не автономный класс
PetNameComparer
:
// Сортировка по дружественному имени становится немного яснее.
Array.Sort(myAutos, Car.SortByPetName);
К настоящему моменту вы должны не только понимать способы определения и реализации собственных интерфейсов, но также оценить их полезность. Конечно, интерфейсы встречаются внутри каждого важного пространства имен .NET Core, а в оставшихся главах книги вы продолжите работать с разнообразными стандартными интерфейсами.
Интерфейс может быть определен как именованная коллекция абстрактных членов. Интерфейс общепринято расценивать как поведение, которое может поддерживаться заданным типом. Когда два или больше число типов реализуют один и тот же интерфейс, каждый из них может трактоваться одинаковым образом (полиморфизм на основе интерфейсов), даже если типы определены в разных иерархиях.
Для определения новых интерфейсов в языке C# предусмотрено ключевое слово
interface
. Как было показано в главе, тип может поддерживать столько интерфейсов, сколько необходимо, и интерфейсы указываются в виде списка с разделителями-запятыми. Более того, разрешено создавать интерфейсы, которые являются производными от множества базовых интерфейсов.
В дополнение к построению специальных интерфейсов библиотеки .NET Core определяют набор стандартных (т.е. поставляемых вместе с платформой) интерфейсов. Вы видели, что можно создавать специальные типы, которые реализуют предопределенные интерфейсы с целью поддержки набора желательных возможностей, таких как клонирование, сортировка и перечисление.
К настоящему моменту вы уже умеете создавать специальные типы классов в С#. Теперь вы узнаете, каким образом исполняющая среда управляет размещенными экземплярами классов (т.е. объектами) посредством сборки мусора. Программистам на C# никогда не приходится непосредственно удалять управляемый объект из памяти (вспомните, что в языке C# даже нет ключевого слова наподобие
delete
). Взамен объекты .NET Core размещаются в области памяти, которая называется управляемой кучей, где они автоматически уничтожаются сборщиком мусора "в какой-то момент в будущем".
После изложения основных деталей, касающихся процесса сборки мусора, будет показано, каким образом программно взаимодействовать со сборщиком мусора, используя класс
System.GC
(что в большинстве проектов обычно не требуется). Мы рассмотрим, как с применением виртуального метода System.Object.Finalize()
и интерфейса IDisposable
строить классы, которые своевременно и предсказуемо освобождают внутренние неуправляемые ресурсы.
Кроме того, будут описаны некоторые функциональные возможности сборщика мусора, появившиеся в версии .NET 4.0, включая фоновую сборку мусора и ленивое (отложенное) создание объектов с использованием обобщенного класса
System.Lazy<>
. После освоения материалов данной главы вы должны хорошо понимать, каким образом исполняющая среда управляет объектами .NET Core.
Прежде чем приступить к исследованию основных тем главы, важно дополнительно прояснить отличие между классами, объектами и ссылочными переменными. Вспомните, что класс — всего лишь модель, которая описывает то, как экземпляр такого типа будет выглядеть и вести себя в памяти. Разумеется, классы определяются внутри файлов кода (которым по соглашению назначается расширение
*.cs
). Взгляните на следующий простой класс Car
, определенный в новом проекте консольного приложения C# по имени SimpleGC
:
namespace SimpleGC
{
// Car.cs
public class Car
{
public int CurrentSpeed {get; set;}
public string PetName {get; set;}
public Car(){}
public Car(string name, int speed)
{
PetName = name;
CurrentSpeed = speed;
}
public override string ToString()
=> $"{PetName} is going {CurrentSpeed} MPH";
}
}
}
После того как класс определен, в памяти можно размещать любое количество его объектов, применяя ключевое слово new языка С#. Однако следует иметь в виду, что ключевое слово
new
возвращает ссылку на объект в куче, а не действительный объект. Если ссылочная переменная объявляется как локальная переменная в области действия метода, то она сохраняется в стеке для дальнейшего использования внутри приложения. Для доступа к членам объекта в отношении сохраненной ссылки необходимо применять операцию точки С#:
using System;
using SimpleGC;
Console.WriteLine("***** GC Basics *****");
// Создать новый объект Car в управляемой куче.
// Возвращается ссылка на этот объект (refToMyCar).
Car refToMyCar = new Car("Zippy", 50);
// Операция точки (.) используется для обращения к членам
// объекта с применением ссылочной переменной.
Console.WriteLine(refToMyCar.ToString());
Console.ReadLine();
На заметку! Вспомните из главы 4, что структуры являются типами значений, которые всегда размещаются прямо в стеке и никогда не попадают в управляемую кучу .NET Core. Размещение в куче происходит только при создании экземпляров классов.
При создании приложений C# корректно допускать, что исполняющая среда .NET Core позаботится об управляемой куче без вашего прямого вмешательства. В действительности "золотое правило" по управлению памятью в .NET Core выглядит простым.
Правило. Используя ключевое слово
new
, поместите экземпляр класса в управляемую кучу и забудьте о нем.
После создания объект будет автоматически удален сборщиком мусора, когда необходимость в нем отпадет. Конечно, возникает вполне закономерный вопрос о том, каким образом сборщик мусора выясняет, что объект больше не нужен? Краткий (т.е. неполный) ответ можно сформулировать так: сборщик мусора удаляет объект из кучи, только когда он становится недостижимым для любой части кодовой базы. Добавьте в класс
Program
метод, который размещает в памяти локальный объект Car
:
static void MakeACar()
{
// Если myCar - единственная ссылка на объект Car, то после
// завершения этого метода объект Car *может* быть уничтожен.
Car myCar = new Car();
}
Обратите внимание, что ссылка на объект
Car(myCar)
была создана непосредственно внутри метода MakeACar()
и не передавалась за пределы определяющей области видимости (через возвращаемое значение или параметр ref/out
). Таким образом, после завершения данного метода ссылка myCar
оказывается недостижимой, и объект Car
теперь является кандидатом на удаление сборщиком мусора. Тем не менее, важно понимать, что восстановление занимаемой этим объектом памяти немедленно после завершения метода MakeACar()
гарантировать нельзя. В данный момент можно предполагать лишь то, что когда исполняющая среда инициирует следующую сборку мусора, объект myCar
может быть безопасно уничтожен.
Как вы наверняка сочтете, программирование в среде со сборкой мусора значительно облегчает разработку приложений. И напротив, программистам на языке C++ хорошо известно, что если они не позаботятся о ручном удалении размещенных в куче объектов, тогда утечки памяти не заставят себя долго ждать. На самом деле отслеживание утечек памяти — один из требующих самых больших затрат времени (и утомительных) аспектов программирования в неуправляемых средах. За счет того, что сборщику мусора разрешено взять на себя заботу об уничтожении объектов, обязанности по управлению памятью перекладываются с программистов на исполняющую среду.
Когда компилятор C# сталкивается с ключевым словом new, он вставляет в реализацию метода инструкцию
newobj
языка CIL. Если вы скомпилируете текущий пример кода и заглянете в полученную сборку с помощью утилиты ildasm.ехе
, то найдете внутри метода MakeACar()
следующие операторы CIL:
.method assembly hidebysig static
void '<$>g__MakeACar|0_0'() cil managed
{
// Code size 8 (0x8)
.maxstack 1
.locals init (class SimpleGC.Car V_0)
IL_0000: nop
IL_0001: newobj instance void SimpleGC.Car::.ctor()
IL_0006: stloc.0
IL_0007: ret
} // end of method '$'::'<$>g__MakeACar|0_0'
Прежде чем ознакомиться с точными правилами, которые определяют момент, когда объект должен удаляться из управляемой кучи, давайте более подробно рассмотрим роль инструкции
newobj
языка CIL. Первым делом важно понимать, что управляемая куча представляет собой нечто большее, чем просто произвольную область памяти, к которой исполняющая среда имеет доступ. Сборщик мусора .NET Core "убирает" кучу довольно тщательно, при необходимости даже сжимая пустые блоки памяти в целях оптимизации.
Для содействия его усилиям в управляемой куче поддерживается указатель (обычно называемый указателем на следующий объект или указателем на новый объект), который идентифицирует точное местоположение, куда будет помещен следующий объект. Таким образом, инструкция
newobj
заставляет исполняющую среду выполнить перечисленные ниже основные операции.
1. Подсчитать общий объем памяти, требуемой для размещения объекта (в том числе память, необходимую для членов данных и базовых классов).
2. Выяснить, действительно ли в управляемой куче имеется достаточно пространства для сохранения размещаемого объекта. Если места хватает, то указанный конструктор вызывается, и вызывающий код в конечном итоге получает ссылку на новый объект в памяти, адрес которого совпадает с последней позицией указателя на следующий объект.
3. Наконец, перед возвращением ссылки вызывающему коду переместить указатель на следующий объект, чтобы он указывал на следующую доступную область в управляемой куче.
Описанный процесс проиллюстрирован на рис. 9.2.
В результате интенсивного размещения объектов приложением пространство внутри управляемой кучи может со временем заполниться. Если при обработке инструкции
newobj
исполняющая среда определяет, что в управляемой куче недостаточно места для размещения объекта запрашиваемого типа, тогда она выполнит сборку мусора, пытаясь освободить память. Соответственно, следующее правило сборки мусора выглядит тоже довольно простым.
Правило. Если в управляемой куче не хватает пространства для размещения требуемого объекта, то произойдет сборка мусора.
Однако то, как конкретно происходит сборка мусора, зависит от типа сборки мусора, используемого приложением. Различия будут описаны далее в главе.
Программисты на C/C++ часто устанавливают переменные указателей в
null
, гарантируя тем самым, что они больше не ссылаются на какие-то местоположения в неуправляемой памяти. Учитывая такой факт, вас может интересовать, что происходит в результате установки в null
ссылок на объекты в С#. В качестве примера измените метод MakeACar()
следующим образом:
static void MakeACar()
{
Car myCar = new Car();
myCar = null;
}
Когда ссылке на объект присваивается
null
, компилятор C# генерирует код CIL, который гарантирует, что ссылка (myCar
в данном примере) больше не указывает на какой-либо объект. Если теперь снова с помощью утилиты ildasm.exe
просмотреть код CIL модифицированного метода MakeACar()
, то можно обнаружить в нем код операции ldnull
(заталкивает значение null
в виртуальный стек выполнения), за которым следует код операции stloc.0
(устанавливает для переменной ссылку null
):
.method assembly hidebysig static
void '<$>g__MakeACar|0_0'() cil managed
{
// Code size 10 (0xa)
.maxstack 1
.locals init (class SimpleGC.Car V_0)
IL_0000: nop
IL_0001: newobj instance void SimpleGC.Car::.ctor()
IL_0006: stloc.0
IL_0007: ldnull
IL_0008: stloc.0
IL_0009: ret
} // end of method '$'::'<$>g__MakeACar|0_0'
Тем не менее, вы должны понимать, что присваивание ссылке значения
null
ни в коей мере не вынуждает сборщик мусора немедленно запуститься и удалить объект из кучи. Единственное, что при этом достигается — явный разрыв связи между ссылкой и объектом, на который она ранее указывала. Таким образом, установка ссылок в null в C# имеет гораздо меньше последствий, чем в других языках, основанных на С; однако никакого вреда она определенно не причиняет.
Теперь вернемся к вопросу о том, как сборщик мусора определяет момент, когда объект больше не нужен. Для выяснения, активен ли объект, сборщик мусора использует следующую информацию.
• Корневые элементы в стеке: переменные в стеке, предоставляемые компилятором и средством прохода по стеку.
• Дескрипторы сборки мусора: дескрипторы, указывающие на объекты, на которые можно ссылаться из кода или исполняющей среды.
• Статические данные: статические объекты в доменах приложений, которые могут ссылаться на другие объекты.
Во время процесса сборки мусора исполняющая среда будет исследовать объекты в управляемой куче с целью выяснения, являются ли они по-прежнему достижимыми (т.е. корневыми) для приложения. Для такой цели исполняющая среда будет строить граф объектов, который представляет каждый достижимый объект в куче. Более подробно графы объектов объясняются во время рассмотрения сериализации объектов в главе 20. Пока достаточно знать, что графы объектов применяются для документирования всех достижимых объектов. Кроме того, имейте в виду, что сборщик мусора никогда не будет создавать граф для того же самого объекта дважды, избегая необходимости в выполнении утомительного подсчета циклических ссылок, который характерен при программировании СОМ.
Предположим, что в управляемой куче находится набор объектов с именами А, В, С, D, E, F и G. Во время сборки мусора эти объекты (а также любые внутренние объектные ссылки, которые они могут содержать) будут проверяться. После построения графа недостижимые объекты (пусть ими будут объекты С и F) помечаются как являющиеся мусором. На рис. 9.3 показан возможный граф объектов для только что описанного сценария (линии со стрелками можно читать как "зависит от" или "требует", т.е. Е зависит от G и В, А не зависит ни от чего и т.д.).
После того как объекты помечены для уничтожения (в данном случае С и F, т.к. они не учтены в графе объектов), они удаляются из памяти. Оставшееся пространство в куче сжимается, что в свою очередь вынуждает исполняющую среду изменить лежащие в основе указатели для ссылки на корректные местоположения в памяти (это делается автоматически и прозрачно). И последнее, но не менее важное действие — указатель на следующий объект перенастраивается так, чтобы указывать на следующую доступную область памяти. Конечный результат описанных изменений представлен на рис. 9.4.
На заметку! Строго говоря, сборщик мусора использует две отдельные кучи, одна из которых предназначена специально для хранения крупных объектов. Во время сборки мусора обращение к данной куче производится менее часто из-за возможного снижения производительности, связанного с перемещением больших объектов. В .NET Core куча для хранения крупных объектов может быть уплотнена по запросу или при достижении необязательных жестких границ, устанавливающих абсолютную или процентную степень использования памяти.
Когда исполняющая среда пытается найти недостижимые объекты, она не проверяет буквально каждый объект, помещенный в управляемую кучу. Очевидно, это потребовало бы значительного времени, тем более в крупных (т.е. реальных) приложениях.
Для содействия оптимизации процесса каждому объекту в куче назначается специфичное "поколение". Лежащая в основе поколений идея проста: чем дольше объект существует в куче, тем выше вероятность того, что он там будет оставаться. Например, класс, который определяет главное окно настольного приложения, будет находиться в памяти вплоть до завершения приложения. С другой стороны объекты, которые были помещены в кучу только недавно (такие как объект, размещенный внутри области действия метода), по всей видимости, довольно быстро станут недостижимыми. Исходя из таких предположений, каждый объект в куче принадлежит совокупности одного из перечисленных ниже поколений.
• Поколение 0. Идентифицирует новый размещенный в памяти объект, который еще никогда не помечался как подлежащий сборке мусора (за исключением крупных объектов, изначально помещаемых в совокупность поколения 2). Большинство объектов утилизируются сборщиком мусора в поколении 0 и не доживают до поколения 1.
• Поколение 1. Идентифицирует объект, который уже пережил одну сборку мусора. Это поколение также служит буфером между кратко и длительно существующими объектами.
• Поколение 2. Идентифицирует объект, которому удалось пережить более одной очистки сборщиком мусора, или весьма крупный объект, появившийся в совокупности поколения 2.
На заметку! Поколения 0 и 1 называются эфемерными (недолговечными). В следующем разделе будет показано, что процесс сборки мусора трактует эфемерные поколения по-разному.
Сначала сборщик мусора исследует все объекты, относящиеся к поколению 0. Если пометка и удаление (или освобождение) таких объектов в результате обеспечивают требуемый объем свободной памяти, то любые уцелевшие объекты повышаются до поколения 1. Чтобы увидеть, каким образом поколение объекта влияет на процесс сборки мусора, взгляните на рис. 9.5, где схематически показано, как набор уцелевших объектов поколения 0 (А, В и E) повышается до следующего поколения после восстановления требуемого объема памяти.
Если все объекты поколения 0 проверены, но по-прежнему требуется дополнительная память, тогда начинают исследоваться на предмет достижимости и подвергаться сборке мусора объекты поколения 1. Уцелевшие объекты поколения 1 повышаются до поколения 2. Если же сборщику мусора все еще требуется дополнительная память, то начинают проверяться объекты поколения 2. На этом этапе объекты поколения 2, которым удается пережить сборку мусора, остаются объектами того же поколения 2, учитывая заранее определенный верхний предел поколений объектов.
В заключение следует отметить, что за счет назначения объектам в куче определенного поколения более новые объекты (такие как локальные переменные) будут удаляться быстрее, в то время как более старые (наподобие главного окна приложения) будут существовать дольше.
Сборка мусора инициируется, когда в системе оказывается мало физической памяти, когда объем памяти, выделенной в физической куче, превышает приемлемый порог или когда в коде приложения вызывается метод
GC.Collect()
.
Если все описанное выглядит слегка удивительным и более совершенным, чем необходимость в самостоятельном управлении памятью, тогда имейте в виду, что процесс сборки мусора не обходится без определенных затрат. Время сборки мусора и то, что будет подвергаться сборке, обычно не находится под контролем разработчиков, хотя сборка мусора безусловно может расцениваться положительно или отрицательно. Кроме того, выполнение сборки мусора приводит к расходу циклов центрального процессора (ЦП) и может повлиять на производительность приложения. В последующих разделах исследуются различные типы сборки мусора.
Как упоминалось ранее, поколения 0 и 1 существуют недолго и называются эфемерными поколениями. Эти поколения размещаются в памяти, которая известна как эфемерный сегмент. Когда происходит сборка мусора, запрошенные сборщиком мусора новые сегменты становятся новыми эфемерными сегментами, а сегменты, содержащие объекты, которые уцелели в прошедшем поколении 1, образуют новый сегмент поколения 2. Размер эфемерного сегмента зависит от ряда факторов, таких как тип сборки мусора (рассматривается следующим) и разрядность системы. Размеры эфемерных сегментов описаны в табл. 9.1.
Исполняющая среда поддерживает два описанных ниже типа сборки мусора.
• Сборка мусора на рабочей станции. Тип сборки мусора, который спроектирован для клиентских приложений и является стандартным для автономных приложений. Сборка мусора на рабочей станции может быть фоновой (обсуждается ниже) или выполняться в непараллельном режиме.
• Сборка мусора на сервере. Тип сборки мусора, спроектированный для серверных приложений, которым требуется высокая производительность и масштабируемость. Подобно сборке мусора на рабочей станции сборка мусора на сервере может быть фоновой или выполняться в непараллельном режиме.
На заметку! Названия служат признаком стандартных настроек для приложений рабочей станции и сервера, но метод сборки мусора можно настраивать через файл
runtimeconfig.json
или переменные среды системы. При наличии на компьютере только одного ЦП будет всегда использоваться сборка мусора на рабочей станции.
Сборка мусора на рабочей станции производится в том же потоке, где она была инициирована, и сохраняет тот же самый приоритет, который был назначен во время запуска. Это может привести к состязанию с другими потоками в приложении.
Сборка мусора на сервере осуществляется в нескольких выделенных потоках, которым назначен уровень приоритета
THREAD_PRIORITY_HIGHEST
(тема многопоточности раскрывается в главе 15). Для выполнения сборки мусора каждый ЦП получает выделенную кучу и отдельный поток. В итоге сборка мусора на сервере может стать крайне ресурсоемкой.
Начиная с версии .NET 4.0 и продолжая в .NET Core, сборщик мусора способен решать вопрос с приостановкой потоков при очистке объектов в управляемой куче, используя фоновую сборку мусора. Несмотря на название приема, это вовсе не означает, что вся сборка мусора теперь происходит в дополнительных фоновых потоках выполнения. На самом деле, если фоновая сборка мусора производится для объектов, принадлежащих к неэфемерному поколению, то исполняющая среда .NET Core может выполнять сборку мусора в отношении объектов эфемерных поколений внутри отдельного фонового потока.
В качестве связанного замечания: механизм сборки мусора в .NET 4.0 и последующих версиях был усовершенствован с целью дальнейшего сокращения времени приостановки заданного потока, которая связана со сборкой мусора. Конечным результатом таких изменений стало то, что процесс очистки неиспользуемых объектов поколения 0 или поколения 1 был оптимизирован и позволяет обеспечить более высокую производительность приложений (что действительно важно для систем реального времени, которые требуют небольших и предсказуемых перерывов на сборку мусора).
Тем не менее, важно понимать, что ввод новой модели сборки мусора совершенно не повлиял на способ построения приложений .NET Core. С практической точки зрения вы можете просто разрешить сборщику мусора выполнять свою работу без непосредственного вмешательства с вашей стороны (и радоваться тому, что разработчики в Microsoft продолжают улучшать процесс сборки мусора в прозрачной манере).
В сборке
mscorlib.dll
предоставляется класс по имени System.GC
, который позволяет программно взаимодействовать со сборщиком мусора, применяя набор статических членов. Имейте в виду, что необходимость в прямом взаимодействии с классом System.GC
внутри разрабатываемого кода возникает редко (если вообще возникает). Обычно единственной ситуацией, когда будут использоваться члены System.GC
, является создание классов, которые внутренне работают с неуправляемыми ресурсами. Например, может строиться класс, в котором присутствуют вызовы API-интерфейса Windows, основанного на С, с применением протокола обращения к платформе .NET Core, или какая-то низкоуровневая и сложная логика взаимодействия с СОМ. В табл. 9.2 приведено краткое описание некоторых наиболее интересных членов класса System.GC
(полные сведения можно найти в документации по .NET Core).
Чтобы проиллюстрировать использование типа
System.GC
для получения разнообразных деталей, связанных со сборкой мусора, обновите операторы верхнего уровня в проекте SimpleGC
:
using System;
Console.WriteLine("***** Fun with System.GC *****");
// Вывести оценочное количество байтов, выделенных в куче.
Console.WriteLine("Estimated bytes on heap: {0}",
GC.GetTotalMemory(false));
// Значения MaxGeneration начинаются c 0, поэтому при выводе добавить 1.
Console.WriteLine("This OS has {0} object generations.\n",
(GC.MaxGeneration + 1));
Car refToMyCar = new Car("Zippy", 100);
Console.WriteLine(refToMyCar.ToString());
// Вывести поколение объекта refToMyCar.
Console.WriteLine("Generation of refToMyCar is: {0}",
GC.GetGeneration(refToMyCar));
Console.ReadLine();
Вы должны получить примерно такой вывод:
***** Fun with System.GC *****
Estimated bytes on heap: 75760
This OS has 3 object generations.
Zippy is going 100 MPH
Generation of refToMyCar is: 0
Методы из табл. 9.2 более подробно обсуждаются в следующем разделе.
Не забывайте о том, что основное предназначение сборщика мусора связано с управлением памятью вместо программистов. Однако в ряде редких обстоятельств сборщик мусора полезно запускать принудительно, используя метод
GC.Collect()
. Взаимодействие с процессом сборки мусора требуется в двух ситуациях:
• приложение входит в блок кода, который не должен быть прерван вероятной сборкой мусора;
• приложение только что закончило размещение исключительно большого количества объектов, и вы хотите насколько возможно скоро освободить крупный объем выделенной памяти.
Если вы посчитаете, что принудительная проверка сборщиком мусора наличия недостижимых объектов может принести пользу, тогда можете явно инициировать процесс сборки мусора:
...
// Принудительно запустить сборку мусора
// и ожидать финализации каждого объекта.
GC.Collect();
GC.WaitForPendingFinalizers();
...
При запуске сборки мусора вручную всегда должен вызываться метод
GC.WaitForPendingFinalizers()
. Благодаря такому подходу можно иметь уверенность в том, что все финализируемые объекты (описанные в следующем разделе) получат шанс выполнить любую необходимую очистку перед продолжением работы программы. "За кулисами" метод GC.WaitForPendingFinalizers()
приостановит вызывающий поток на время прохождения сборки мусора. Это очень хорошо, т.к. гарантирует невозможность обращения в коде к методам объекта, который в текущий момент уничтожается.
Методу
GC.Collect()
можно также предоставить числовое значение, идентифицирующее самое старое поколение, для которого будет выполняться сборка мусора. Например, чтобы проинструктировать исполняющую среду о необходимости исследования только объектов поколения 0, можно написать такой код:
...
// Исследовать только объекты поколения 0.
GC.Collect(0);
GC.WaitForPendingFinalizers();
...
Кроме того, методу
Collect()
можно передать во втором параметре значение перечисления GCCollectionMode
для точной настройки способа, которым исполняющая среда должна принудительно инициировать сборку мусора. Ниже показаны значения, определенные этим перечислением:
public enum GCCollectionMode
{
Default, // Текущим стандартным значением является Forced.
Forced, // Указывает исполняющей среде начать сборку мусора немедленно
Optimized // Позволяет исполняющей среде выяснить, оптимален
// ли текущий момент для удаления объектов.
}
Как и при любой сборке мусора, в результате вызова
GC.Collect()
уцелевшие объекты переводятся в более высокие поколения. Модифицируйте операторы верхнего уровня следующим образом:
Console.WriteLine("***** Fun with System.GC *****");
// Вывести оценочное количество байтов, выделенных в куче.
Console.WriteLine("Estimated bytes on heap: {0}",
GC.GetTotalMemory(false));
// Значения MaxGeneration начинаются c 0.
Console.WriteLine("This OS has {0} object generations.\n",
(GC.MaxGeneration + 1));
Car refToMyCar = new Car("Zippy", 100);
Console.WriteLine(refToMyCar.ToString());
// Вывести поколение refToMyCar.
Console.WriteLine("\nGeneration of refToMyCar is: {0}",
GC.GetGeneration(refToMyCar));
// Создать большое количество объектов для целей тестирования.
object[] tonsOfObjects = new object[50000];
for (int i = 0; i < 50000; i++)
{
tonsOfObjects[i] = new object();
}
// Выполнить сборку мусора только для объектов поколения 0.
Console.WriteLine("Force Garbage Collection");
GC.Collect(0, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();
// Вывести поколение refToMyCar.
Console.WriteLine("Generation of refToMyCar is: {0}",
GC.GetGeneration(refToMyCar));
// Посмотреть, существует ли еще tonsOfObjects[9000].
if (tonsOfObjects[9000] != null)
{
Console.WriteLine("Generation of tonsOfObjects[9000] is: {0}",
GC.GetGeneration(tonsOfObjects[9000]));
}
else
{
Console.WriteLine("tonsOfObjects[9000] is no longer alive.");
// tonsOfObjects[9000] больше не существует
}
// Вывести количество проведенных сборок мусора для разных поколений.
Console.WriteLine("\nGen 0 has been swept {0} times",
GC.CollectionCount(0)); // Количество сборок для поколения 0
Console.WriteLine("Gen 1 has been swept {0} times",
GC.CollectionCount(1)); // Количество сборок для поколения 1
Console.WriteLine("Gen 2 has been swept {0} times",
GC.CollectionCount(2)); // Количество сборок для поколения 2
Console.ReadLine();
Здесь в целях тестирования преднамеренно был создан большой массив типа
object
(состоящий из 50000 элементов). Ниже показан вывод программы:
***** Fun with System.GC *****
Estimated bytes on heap: 75760
This OS has 3 object generations.
Zippy is going 100 MPH
Generation of refToMyCar is: 0
Forcing Garbage Collection
Generation of refToMyCar is: 1
Generation of tonsOfObjects[9000] is: 1
Gen 0 has been swept 1 times
Gen 1 has been swept 0 times
Gen 2 has been swept 0 times
К настоящему моменту вы должны лучше понимать детали жизненного цикла объектов. В следующем разделе мы продолжим изучение процесса сборки мусора, обратившись к теме создания финализируемых объектов и освобождаемых объектов. Имейте в виду, что описываемые далее приемы обычно необходимы только при построении классов С#, которые поддерживают внутренние неуправляемые ресурсы.
В главе 6 вы узнали, что в самом главном базовом классе .NET Core,
System.Object
, определен виртуальный метод по имени Finalize()
. В своей стандартной реализации он ничего не делает:
// System.Object
public class Object
{
...
protected virtual void Finalize() {}
}
За счет переопределения метода
Finalize()
в специальных классах устанавливается специфическое место для выполнения любой логики очистки, необходимой данному типу. Учитывая, что метод Finalize()
определен как защищенный, вызывать его напрямую из экземпляра класса через операцию точки нельзя. Взамен метод Finalize()
, если он поддерживается, будет вызываться сборщиком мусора перед удалением объекта из памяти.
На заметку! Переопределять метод
Finalize()
в типах структур не разрешено. Подобное ограничение вполне логично, поскольку структуры являются типами значений, которые изначально никогда не размещаются в куче и, следовательно, никогда не подвергаются сборке мусора. Тем не менее, при создании структуры, которая содержит неуправляемые ресурсы, нуждающиеся в очистке, можно реализовать интерфейс iDisposable
(вскоре он будет описан). Вспомните из главы 4, что структуры ref
и структуры ref
, допускающие только чтение, не могут реализовывать какой-либо интерфейс, но могут реализовывать метод Dispose()
.
Разумеется, вызов метода
Finalize()
будет происходить (в итоге) во время "естественной" сборки мусора или в случае ее принудительного запуска внутри кода с помощью GC.Collect()
. В предшествующих версиях .NET (но не в .NET Core) финализатор каждого объекта вызывался при окончании работы приложения. В .NET Core нет никаких способов принудительного запуска финализатора даже при завершении приложения.
О чем бы ни говорили ваши инстинкты разработчика, подавляющее большинство классов C# не требует написания явной логики очистки или специального финализатора. Причина проста: если в классах используются лишь другие управляемые объекты, то все они в конечном итоге будут подвергнуты сборке мусора. Единственная ситуация, когда может возникнуть потребность спроектировать класс, способный выполнять после себя очистку, предусматривает работу с неуправляемыми ресурсами (такими как низкоуровневые файловые дескрипторы операционной системы, низкоуровневые неуправляемые подключения к базам данных, фрагменты неуправляемой памяти и т.д.).
В рамках платформы .NET Core неуправляемые ресурсы получаются путем прямого обращения к API-интерфейсу операционной системы с применением служб вызова платформы (Platform Invocation Services — P/Invoke) или в сложных сценариях взаимодействия с СОМ. С учетом сказанного можно сформулировать еще одно правило сборки мусора.
Правило. Единственная серьезная причина для переопределения метода
Finalize()
связана с использованием в классе C# неуправляемых ресурсов через P/Invoke или сложные задачи взаимодействия с СОМ (обычно посредством разнообразных членов типа System.Runtime.InteropServices.Marshal
). Это объясняется тем, что в таких сценариях производится манипулирование памятью, которой исполняющая среда управлять не может.
В том редком случае, когда строится класс С#, в котором применяются неуправляемые ресурсы, вы вполне очевидно захотите обеспечить предсказуемое освобождение занимаемой памяти. В качестве примера создадим новый проект консольного приложения C# по имени
SimpleFinalize
и вставим в него класс MyResourceWrapper
, в котором используется неуправляемый ресурс (каким бы он ни был). Теперь необходимо переопределить метод Finalize()
. Как ни странно, для этого нельзя применять ключевое слово override
языка С#:
using System;
namespace SimpleFinalize
{
class MyResourceWrapper
{
// Compile-time error!
protected override void Finalize(){ }
}
}
На самом деле для обеспечения того же самого эффекта используется синтаксис деструктора (подобный C++). Причина такой альтернативной формы переопределения виртуального метода заключается в том, что при обработке синтаксиса финализатора компилятор автоматически добавляет внутрь неявно переопределяемого метода
Finalize()
много обязательных инфраструктурных элементов (как вскоре будет показано).
Финализаторы C# выглядят похожими на конструкторы тем, что именуются идентично классу, в котором определены. Вдобавок они снабжаются префиксом в виде тильды (
~
). Однако в отличие от конструкторов финализаторы никогда не получают модификатор доступа (они всегда неявно защищенные), не принимают параметров и не могут быть перегружены (в каждом классе допускается наличие только одного финализатора). Ниже приведен специальный финализатор для класса MyResourceWrapper
, который при вызове выдает звуковой сигнал. Очевидно, такой пример предназначен только для демонстрационных целей. В реальном приложении финализатор только освобождает любые неуправляемые ресурсы и не взаимодействует с другими управляемыми объектами, даже с теми, на которые ссылается текущий объект, т.к. нельзя предполагать, что они все еще существуют на момент вызова этого метода Finalize()
сборщиком мусора.
using System;
// Переопределить System.Object.Finalize()
// посредством синтаксиса финализатора.
class MyResourceWrapper
{
// Очистить неуправляемые ресурсы.
// Выдать звуковой сигнал при уничтожении
// (только в целях тестирования)
~MyResourceWrapper() => Console.Beep();
}
Если теперь просмотреть код CIL данного финализатора с помощью утилиты
ildasm.exe
, то обнаружится, что компилятор добавил необходимый код для проверки ошибок. Первым делом операторы внутри области действия метода Finalize()
помещены в блок try
(см. главу 7). Связанный с ним блок finally
гарантирует, что методы Finalize()
базовых классов будут всегда выполняться независимо от любых исключений, возникших в области try
.
.method family hidebysig virtual instance void
Finalize() cil managed
{
.override [System.Runtime]System.Object::Finalize
// Code size 17 (0x11)
.maxstack 1
.try
{
IL_0000: call void [System.Console]System.Console::Beep()
IL_0005: nop
IL_0006: leave.s IL_0010
} // end .try
finally
{
IL_0008: ldarg.0
IL_0009: call instance void [System.Runtime]System.Object::Finalize()
IL_000e: nop
IL_000f: endfinally
} // end handler
IL_0010: ret
} // end of method MyResourceWrapper::Finalize
Тестирование класса
MyResourceWrapper
показывает, что звуковой сигнал выдается при выполнении финализатора:
using System;
using SimpleFinalize;
Console.WriteLine("***** Fun with Finalizers *****\n");
Console.WriteLine("Hit return to create the objects ");
Console.WriteLine("then force the GC to invoke Finalize()");
// Нажмите клавишу , чтобы создать объекты
// и затем заставить сборщик мусора вызвать метод Finalize()
// В зависимости от мощности вашей системы
// вам может понадобиться увеличить эти значения.
CreateObjects(1_000_000);
// Искусственно увеличить уровень давления.
GC.AddMemoryPressure(2147483647);
GC.Collect(0, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();
Console.ReadLine();
static void CreateObjects(int count)
{
MyResourceWrapper[] tonsOfObjects =
new MyResourceWrapper[count];
for (int i = 0; i < count; i++)
{
tonsOfObjects[i] = new MyResourceWrapper();
}
tonsOfObjects = null;
}
На заметку! Единственный способ гарантировать, что такое небольшое консольное приложение принудительно запустит сборку мусора в .NET Core, предусматривает создание огромного количества объектов в памяти и затем установит ссылку на них в null. После запуска этого приложения не забудьте нажать комбинацию клавиш
Важно всегда помнить о том, что роль метода
Finalize()
состоит в обеспечении того, что объект .NET Core сумеет освободить неуправляемые ресурсы, когда он подвергается сборке мусора. Таким образом, если вы строите класс, в котором неуправляемая память не применяется (общепризнанно самый распространенный случай), то финализация принесет мало пользы. На самом деле по возможности вы должны проектировать свои типы так, чтобы избегать в них поддержки метода Finalize()
по той простой причине, что финализация занимает время.
При размещении объекта в управляемой куче исполняющая среда автоматически определяет, поддерживает ли он специальный метод
Finalize()
. Если да, тогда объект помечается как финализируемый, а указатель на него сохраняется во внутренней очереди, называемой очередью финализации. Очередь финализации — это таблица, обслуживаемая сборщиком мусора, в которой содержатся указатели на все объекты, подлежащие финализации перед удалением из кучи.
Когда сборщик мусора решает, что наступило время высвободить объект из памяти, он просматривает каждую запись в очереди финализации и копирует объект из кучи в еще одну управляемую структуру под названием таблица объектов, доступных для финализации. На этой стадии порождается отдельный поток для вызова метода
Finalize()
на каждом объекте из упомянутой таблицы при следующей сборке мусора. Итак, действительная финализация объекта требует, по меньшей мере, двух сборок мусора.
Подводя итоги, следует отметить, что хотя финализация объекта гарантирует ему возможность освобождения неуправляемых ресурсов, она все равно остается недетерминированной по своей природе, а из-за незаметной дополнительной обработки протекает значительно медленнее.
Как вы уже видели, финализаторы могут использоваться для освобождения неуправляемых ресурсов при запуске сборщика мусора. Тем не менее, учитывая тот факт, что многие неуправляемые объекты являются "ценными элементами" (вроде низкоуровневых дескрипторов для файлов или подключений к базам данных), зачастую полезно их освобождать как можно раньше, не дожидаясь наступления сборки мусора. В качестве альтернативы переопределению метода
Finalize()
класс может реализовать интерфейс IDisposable
, в котором определен единственный метод по имени Dispose()
:
public interface IDisposable
{
void Dispose();
}
При реализации интерфейса
IDisposable
предполагается, что когда пользователь объекта завершает с ним работу, он вручную вызывает метод Dispose()
перед тем, как позволить объектной ссылке покинуть область действия. Таким способом объект может производить любую необходимую очистку неуправляемых ресурсов без помещения в очередь финализации и ожидания, пока сборщик мусора запустит логику финализации класса.
На заметку! Интерфейс
IDisposable
может быть реализован структурами не ref
и классами (в отличие от переопределения метода Finalize()
, что допускается только для классов), т.к. метод Dispose()
вызывается пользователем объекта, а не сборщиком мусора. Освобождаемые структуры ref
обсуждались в главе 4.
В целях иллюстрации применения интерфейса
IDisposable
создайте новый проект консольного приложения C# по имени SimpleDispose
. Ниже приведен модифицированный класс MyResourceWrapper
, который вместо переопределения метода System.Object.Finalize()
теперь реализует интерфейс IDisposable
:
using System;
namespace SimpleDispose
{
// Реализация интерфейса IDisposable.
class MyResourceWrapper : IDisposable
{
// После окончания работы с объектом пользователь
.
// объекта должен вызывать этот метод
public void Dispose()
{
// Очистить неуправляемые ресурсы...
.
// Освободить другие освобождаемые объекты, содержащиеся внутри.
// Только для целей тестирования
Console.WriteLine("***** In Dispose! *****");
}
}
}
Обратите внимание, что метод
Dispose()
отвечает не только за освобождение неуправляемых ресурсов самого типа, но может также вызывать методы Dispose()
для любых других освобождаемых объектов, которые содержатся внутри типа. В отличие от Finalize()
в методе Dispose()
вполне безопасно взаимодействовать с другими управляемыми объектами. Причина проста: сборщик мусора не имеет понятия об интерфейсе IDisposable
, а потому никогда не будет вызывать метод Dispose()
. Следовательно, когда пользователь объекта вызывает данный метод, объект все еще существует в управляемой куче и имеет доступ ко всем остальным находящимся там объектам. Логика вызова метода Dispose()
прямолинейна:
using System;
using System.IO;
using SimpleDispose;
Console.WriteLine("***** Fun with Dispose *****\n");
// Создать освобождаемый объект и вызвать метод Dispose()
.
// для освобождения любых внутренних ресурсов
MyResourceWrapper rw = new MyResourceWrapper();
rw.Dispose();
Console.ReadLine();
Конечно, перед попыткой вызова метода
Dispose()
на объекте понадобится проверить, поддерживает ли тип интерфейс IDisposable
. Хотя всегда можно выяснить, какие типы в библиотеках базовых классов реализуют IDisposable
, заглянув в документацию, программная проверка производится с помощью ключевого слова is
или as
(см. главу 6):
Console.WriteLine("***** Fun with Dispose *****\n");
MyResourceWrapper rw = new MyResourceWrapper();
if (rw is IDisposable)
{
rw.Dispose();
}
Console.ReadLine();
Приведенный пример раскрывает очередное правило, касающееся управления памятью.
Правило. Неплохо вызывать метод
Dispose()
на любом создаваемом напрямую объекте, если он поддерживает интерфейс IDisposable
. Предположение заключается в том, что когда проектировщик типа решил реализовать метод Dispose()
, тогда тип должен выполнять какую-то очистку. Если вы забудете вызвать Dispose()
, то память в конечном итоге будет очищена (так что можно не переживать), но это может занять больше времени, чем необходимо.
С предыдущим правилом связано одно предостережение. Несколько типов в библиотеках базовых классов, которые реализуют интерфейс
IDisposable
, предоставляют (кое в чем сбивающий с толку) псевдоним для метода Dispose()
в попытке сделать имя метода очистки более естественным для определяющего его типа. В качестве примера можно взять класс System.IO.FileStream
, который реализует интерфейс IDisposable
(и потому поддерживает метод Dispose()
), но также определяет следующий метод Close()
, предназначенный для той же цели:
// Предполагается, что было импортировано пространство имен System.IO
static void DisposeFileStream()
{
FileStream fs = new FileStream("myFile.txt", FileMode.OpenOrCreate);
// Мягко выражаясь, сбивает с толку!
// Вызовы этих методов делают одно и то же!
fs.Close();
fs.Dispose();
}
В то время как "закрытие" (close) файла выглядит более естественным, чем его "освобождение" (dispose), подобное дублирование методов очистки может запутывать. При работе с типами, предлагающими псевдонимы, просто помните о том, что если тип реализует интерфейс
IDisposable
, то вызов метода Dispose()
всегда является безопасным способом действия.
Имея дело с управляемым объектом, который реализует интерфейс
IDisposable
, довольно часто приходится применять структурированную обработку исключений, гарантируя тем самым, что метод Dispose()
типа будет вызываться даже в случае генерации исключения во время выполнения:
Console.WriteLine("***** Fun with Dispose *****\n");
MyResourceWrapper rw = new MyResourceWrapper ();
try
{
// Использовать члены rw.
}
finally
{
// Всегда вызывать Dispose(), возникла ошибка или нет.
rw.Dispose();
}
Хотя это является хорошим примером защитного программирования, в действительности лишь немногих разработчиков привлекает перспектива помещения каждого освобождаемого типа внутрь блока
try/finally
, просто чтобы гарантировать вызов метода Dispose()
. Того же самого результата можно достичь гораздо менее навязчивым способом, используя специальный фрагмент синтаксиса С#, который выглядит следующим образом:
Console.WriteLine("***** Fun with Dispose *****\n");
// Метод Dispose() вызывается автоматически
// при выходе за пределы области действия using.
using(MyResourceWrapper rw = new MyResourceWrapper())
{
// Использовать объект rw.
}
Если вы просмотрите код CIL операторов верхнего уровня посредством
ildasm.exe
, то обнаружите, что синтаксис using
на самом деле расширяется до логики try/finally
с вполне ожидаемым вызовом Dispose()
:
.method private hidebysig static void
'$'(string[] args) cil managed
{
...
.try
{
} // end .try
finally
{
IL_0019: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
} // end handler
} // end of method '$'::'$'
На заметку! Попытка применения
using
к объекту, который не реализует интерфейс IDisposable
, приводит к ошибке на этапе компиляции.
Несмотря на то что такой синтаксис устраняет необходимость вручную помещать освобождаемые объекты внутрь блоков
try/finally
, к сожалению, теперь ключевое слово using
в C# имеет двойной смысл (импортирование пространств имен и вызов метода Dispose()
). Однако при работе с типами, которые поддерживают интерфейс IDisposable
, такая синтаксическая конструкция будет гарантировать, что используемый объект автоматический вызовет свой метод Dispose()
по завершении блока using
.
Кроме того, имейте в виду, что внутри
using
допускается объявлять несколько объектов одного и того же типа. Как и можно было ожидать, компилятор вставит код для вызова Dispose()
на каждом объявленном объекте:
// Использовать список с разделителями-запятыми для объявления
// нескольких объектов, подлежащих освобождению.
using(MyResourceWrapper rw = new MyResourceWrapper(),
rw2 = new MyResourceWrapper())
{
// Работать с объектами rw и rw2.
}
В версии C# 8.0 были добавлены объявления
using
. Объявление using
представляет собой объявление переменной, предваренное ключевым словом using
. Функциональность объявления using
будет такой же, как у синтаксиса, описанного в предыдущем разделе, за исключением явного блока кода, помещенного внутрь фигурных скобок ({}
).
Добавьте к своему классу следующий метод:
private static void UsingDeclaration()
{
// Эта переменная будет находиться в области видимости
// вплоть до конца метода.
using var rw = new MyResourceWrapper();
// Сделать что-нибудь.
Console.WriteLine("About to dispose.");
// В этой точке переменная освобождается.
}
Далее добавьте к своим операторам верхнего уровня показанный ниже вызов:
Console.WriteLine("***** Fun with Dispose *****\n");
...
Console.WriteLine("Demonstrate using declarations");
UsingDeclaration();
Console.ReadLine();
Если вы изучите новый метод с помощью
ildasm.exe
, то (вполне ожидаемо) обнаружите тот же код, что и ранее:
.method private hidebysig static
void UsingDeclaration() cil managed
{
...
.try
{
...
} // end .try
finally
{
IL_0018: callvirt instance void
[System.Runtime]System.IDisposable::Dispose()
...
} // end handler
IL_001f: ret
} // end of method Program::UsingDeclaration
По сути, это новое средство является "магией" компилятора, позволяющей сэкономить несколько нажатий клавиш. При его использовании соблюдайте осторожность, т.к. новый синтаксис не настолько ясен, как предыдущий.
К настоящему моменту вы видели два разных подхода к конструированию класса, который очищает внутренние неуправляемые ресурсы. С одной стороны, можно применять финализатор. Использование такого подхода дает уверенность в том, что объект будет очищать себя сам во время сборки мусора (когда бы она ни произошла) без вмешательства со стороны пользователя. С другой стороны, можно реализовать интерфейс
IDisposable
и предоставить пользователю объекта способ очистки объекта по окончании работы с ним. Тем не менее, если пользователь объекта забудет вызвать метод Dispose()
, то неуправляемые ресурсы могут оставаться в памяти неопределенно долго.
Нетрудно догадаться, что в одном определении класса можно смешивать оба подхода, извлекая лучшее из обеих моделей. Если пользователь объекта не забыл вызвать метод
Dispose()
, тогда можно проинформировать сборщик мусора о пропуске процесса финализации, вызвав метод GC.SuppressFinalize()
. Если же пользователь объекта забыл вызвать Dispose()
, то объект со временем будет финализирован и получит шанс освободить внутренние ресурсы. Преимущество здесь в том, что внутренние неуправляемые ресурсы будут тем или иным способом освобождены.
Ниже представлена очередная версия класса
MyResourceWrapper
, который теперь является финализируемым и освобождаемым; она определена в проекте консольного приложения C# по имени FinalizableDisposableClass
:
using System;
namespace FinalizableDisposableClass
{
// Усовершенствованная оболочка для ресурсов.
public class MyResourceWrapper : IDisposable
{
// Сборщик мусора будет вызывать этот метод, если
// пользователь объекта забыл вызвать Dispose().
~MyResourceWrapper()
{
// Очистить любые внутренние неуправляемые ресурсы.
// **Не** вызывать Dispose() на управляемых объектах.
}
// Пользователь объекта будет вызывать этот метод
// для как можно более скорой очистки ресурсов.
public void Dispose()
{
// Очистить неуправляемые ресурсы.
// Вызвать Dispose() для других освобождаемых объектов,
// содержащихся внутри.
// Если пользователь вызвал Dispose(), то финализация
// не нужна, поэтому подавить ее.
GC.SuppressFinalize(this);
}
}
}
Обратите внимание, что метод
Dispose()
был модифицирован для вызова метода GC.SuppressFinalize()
, который информирует исполняющую среду о том, что вызывать деструктор при обработке данного объекта сборщиком мусора больше не обязательно, т.к. неуправляемые ресурсы уже освобождены посредством логики Dispose()
.
Текущая реализация класса
MyResourceWrapper
работает довольно хорошо, но осталось еще несколько небольших недостатков. Во-первых, методы Finalize()
и Dispose()
должны освобождать те же самые неуправляемые ресурсы. Это может привести к появлению дублированного кода, что существенно усложнит сопровождение. В идеале следовало бы определить закрытый вспомогательный метод и вызывать его внутри указанных методов.
Во-вторых, желательно удостовериться в том, что метод
Finalize()
не пытается освободить любые управляемые объекты, когда такие действия должен делать метод Dispose()
. В-третьих, имеет смысл также позаботиться о том, чтобы пользователь объекта мог безопасно вызывать метод Dispose()
много раз без возникновения ошибки. В настоящий момент защита подобного рода в методе Dispose()
отсутствует.
Для решения таких проектных задач в Microsoft определили формальный шаблон освобождения, который соблюдает баланс между надежностью, удобством сопровождения и производительностью. Вот окончательная версия класса
MyResourceWrapper
, в которой применяется официальный шаблон:
class MyResourceWrapper : IDisposable
{
// Используется для выяснения, вызывался ли метод Dispose().
private bool disposed = false;
public void Dispose()
{
// Вызвать вспомогательный метод.
// Указание true означает, что очистку
// запустил пользователь объекта.
CleanUp(true);
// Подавить финализацию.
GC.SuppressFinalize(this);
}
private void CleanUp(bool disposing)
{
// Удостовериться, не выполнялось ли уже освобождение
if (!this.disposed)
{
// Если disposing равно true, тогда
// освободить все управляемые ресурсы.
if (disposing)
{
// Освободить управляемые ресурсы.
}
// Очистить неуправляемые ресурсы.
}
disposed = true;
}
~MyResourceWrapper()
{
// Вызвать вспомогательный метод.
// Указание false означает, что
// очистку запустил сборщик мусора.
CleanUp(false);
}
}
Обратите внимание, что в
MyResourceWrapper
теперь определен закрытый вспомогательный метод по имени Cleanup()
. Передавая ему true в качестве аргумента, мы указываем, что очистку инициировал пользователь объекта, поэтому должны быть очищены все управляемые и неуправляемые ресурсы. Однако когда очистка инициируется сборщиком мусора, при вызове методу Cleanup()
передается значение false
, чтобы внутренние освобождаемые объекты не освобождались (поскольку нельзя рассчитывать на то, что они все еще присутствуют в памяти). И, наконец, перед выходом из Cleanup()
переменная-член disposed
типа bool
устанавливается в true
, что дает возможность вызывать метод Dispose()
много раз без возникновения ошибки.
На заметку! После того как объект был "освобожден", клиент по-прежнему может обращаться к его членам, т.к. объект пока еще находится в памяти. Следовательно, в надежном классе оболочки для ресурсов каждый член также необходимо снабдить дополнительной логикой, которая бы сообщала: "если объект освобожден, то ничего не делать, а просто возвратить управление".
Чтобы протестировать финальную версию класса
MyResourceWrapper
, модифицируйте свой файл Program.cs
, как показано ниже:
using System;
using FinalizableDisposableClass;
Console.WriteLine("***** Dispose() / Destructor Combo Platter *****");
// Вызвать метод Dispose() вручную, что не приводит к вызову финализатора.
MyResourceWrapper rw = new MyResourceWrapper();
rw.Dispose();
// He вызывать метод Dispose(). Это запустит финализатор,
// когда объект будет обрабатываться сборщиком мусора.
MyResourceWrapper rw2 = new MyResourceWrapper();
В коде явно вызывается метод
Dispose()
на объекте rw, поэтому вызов деструктора подавляется. Тем не менее, мы "забыли" вызвать метод Dispose()
на объекте rw2
; переживать не стоит — финализатор все равно выполнится при обработке объекта сборщиком мусора.
На этом исследование особенностей управления объектами со стороны исполняющей среды через сборку мусора завершено. Хотя дополнительные (довольно экзотические) детали, касающиеся процесса сборки мусора (такие как слабые ссылки и восстановление объектов), здесь не рассматривались, полученных сведений должно быть вполне достаточно, чтобы продолжить изучение самостоятельно. В завершение главы мы взглянем на программное средство под названием ленивое (отложенное) создание объектов.
При создании классов иногда приходится учитывать, что отдельная переменная-член на самом деле может никогда не понадобиться из-за того, что пользователь объекта не будет обращаться к методу (или свойству), в котором она используется. Действительно, подобное происходит нередко. Однако проблема может возникнуть, если создание такой переменной-члена сопряжено с выделением большого объема памяти.
В качестве примера предположим, что строится класс, который инкапсулирует операции цифрового музыкального проигрывателя. В дополнение к ожидаемым методам вроде
Play()
, Pause()
и Stop()
вы также хотите обеспечить возможность возвращения коллекции объектов Song
(посредством класса по имени AllTracks
), которая представляет все имеющиеся на устройстве цифровые музыкальные файлы.
Создайте новый проект консольного приложения по имени
LazyObjectInstantiation
и определите в нем следующие классы:
// Song.cs
namespace LazyObjectInstantiation
{
// Представляет одиночную композицию.
class Song
{
public string Artist { get; set; }
public string TrackName { get; set; }
public double TrackLength { get; set; }
}
}
// AllTracks.cs
using System;
namespace LazyObjectInstantiation
{
// Представляет все композиции в проигрывателе.
class AllTracks
{
// Наш проигрыватель может содержать
// максимум 10 000 композиций.
private Song[] _allSongs = new Song[10000];
public AllTracks()
{
// Предположим, что здесь производится
// заполнение массива объектов Song.
Console.WriteLine("Filling up the songs!");
}
}
}
// MediaPlayer.cs
using System;
namespace LazyObjectInstantiation
{
// Объект MediaPlayer имеет объекты AllTracks.
class MediaPlayer
{
// Предположим, что эти методы делают что-то полезное.
public void Play() { /* Воспроизведение композиции */ }
public void Pause() { /* Пауза в воспроизведении */ }
public void Stop() { /* Останов воспроизведения */ }
private AllTracks _allSongs = new AllTracks();
public AllTracks GetAllTracks()
{
// Возвратить все композиции.
return _allSongs;
}
}
}
В текущей реализации
MediaPlayer
предполагается, что пользователь объекта пожелает получать список объектов с помощью метода GetAllTracks()
. Хорошо, а что если пользователю объекта такой список не нужен? В этой реализации память под переменную-член AllTracks
по-прежнему будет выделяться, приводя тем самым к созданию 10 000 объектов Song
в памяти:
using System;
using LazyObjectInstantiation;
Console.WriteLine("***** Fun with Lazy Instantiation *****\n");
// В этом вызывающем коде получение всех композиций не производится,
// но косвенно все равно создаются 10 000 объектов!
MediaPlayer myPlayer = new MediaPlayer();
myPlayer.Play();
Console.ReadLine();
Безусловно, лучше не создавать 10 000 объектов, с которыми никто не будет работать, потому что в результате нагрузка на сборщик мусора .NET Core намного увеличится. В то время как можно вручную добавить код, который обеспечит создание объекта
_allSongs
только в случае, если он применяется (скажем, используя шаблон фабричного метода), есть более простой путь.
Библиотеки базовых классов предоставляют удобный обобщенный класс по имени
Lazy<>
, который определен в пространстве имен System
внутри сборки mscorlib.dll
. Он позволяет определять данные, которые не будут создаваться до тех пор, пока действительно не начнут применяться в коде. Поскольку класс является обобщенным, при первом его использовании вы должны явно указать тип создаваемого элемента, которым может быть любой тип из библиотек базовых классов .NET Core или специальный тип, построенный вами самостоятельно. Чтобы включить отложенную инициализацию переменной-члена AllTracks
, просто приведите код MediaPlayer
к следующему виду:
// Объект MediaPlayer имеет объект Lazy.
class MediaPlayer
{
...
private Lazy _allSongs = new Lazy();
public AllTracks GetAllTracks()
{
// Возвратить все композиции.
return _allSongs.Value;
}
}
Помимо того факта, что переменная-член
AllTracks
теперь имеет тип Lazy<>
, важно обратить внимание на изменение также и реализации показанного выше метода GetAllTracks()
. В частности, для получения актуальных сохраненных данных (в этом случае объекта AllTracks
, поддерживающего 10 000 объектов Song
) должно применяться доступное только для чтения свойство Value
класса Lazy<>
.
Взгляните, как благодаря такому простому изменению приведенный далее модифицированный код будет косвенно размещать объекты
Song
в памяти, только если метод GetAllTracks()
действительно вызывается:
Console.WriteLine("***** Fun with Lazy Instantiation *****\n");
// Память под объект AllTracks здесь не выделяется!
MediaPlayer myPlayer = new MediaPlayer();
myPlayer.Play();
// Размещение объекта AllTracks происходит
// только в случае вызова метода GetAllTracks().
MediaPlayer yourPlayer = new MediaPlayer();
AllTracks yourMusic = yourPlayer.GetAllTracks();
Console.ReadLine();
На заметку! Ленивое создание объектов полезно не только для уменьшения количества выделений памяти под ненужные объекты. Этот прием можно также использовать в ситуации, когда для создания члена применяется затратный в плане ресурсов код, такой как вызов удаленного метода, взаимодействие с реляционной базой данных и т.п.
При объявлении переменной
Lazy()
действительный внутренний тип данных создается с использованием стандартного конструктора:
// При использовании переменной Lazy() вызывается
// стандартный конструктор класса AllTracks.
private Lazy _allSongs = new Lazy();
В некоторых случаях приведенный код может оказаться приемлемым, но что если класс
AllTracks
имеет дополнительные конструкторы и нужно обеспечить вызов подходящего конструктора? Более того, что если при создании переменной Lazy()
должна выполняться какая-то специальная работа (кроме простого создания объекта AllTracks
)? К счастью, класс Lazy()
позволяет указывать в качестве необязательного параметра обобщенный делегат, который задает метод для вызова во время создания находящегося внутри типа.
Таким обобщенным делегатом является тип
System.Func<>
, который может указывать на метод, возвращающий тот же самый тип данных, что и создаваемый связанной переменной Lazy<>
, и способный принимать вплоть до 16 аргументов (типизированных с применением обобщенных параметров типа). В большинстве случаев никаких параметров для передачи методу, на который указывает Func<>
, задавать не придется. Вдобавок, чтобы значительно упростить работу с типом Func<>
, рекомендуется использовать лямбда-выражения (отношения между делегатами и лямбда-выражениями подробно освещаются в главе 12).
Ниже показана окончательная версия класса
MediaPlayer
, в которой добавлен небольшой специальный код, выполняемый при создании внутреннего объекта AllTracks
. Не забывайте, что перед завершением метод должен возвратить новый экземпляр типа, помещенного в Lazy<>
, причем применять можно любой конструктор по своему выбору (здесь по-прежнему вызывается стандартный конструктор AllTracks
).
class MediaPlayer
{
...
// Использовать лямбда-выражение для добавления дополнительного
// кода, который выполняется при создании объекта AllTracks.
private Lazy _allSongs =
new Lazy( () =>
{
Console.WriteLine("Creating AllTracks object!");
return new AllTracks();
}
);
public AllTracks GetAllTracks()
{
// Возвратить все композиции.
return _allSongs.Value;
}
}
Итак, вы наверняка смогли оценить полезность класса
Lazy<>
. По существу этот обобщенный класс позволяет гарантировать, что затратные в плане ресурсов объекты размещаются в памяти, только когда они требуются их пользователю.
Целью настоящей главы было прояснение процесса сборки мусора. Вы видели, что сборщик мусора запускается, только если не удается получить необходимый объем памяти из управляемой кучи (либо когда разработчик вызывает
GC.Collect()
). Не забывайте о том, что разработанный в Microsoft алгоритм сборки мусора хорошо оптимизирован и предусматривает использование поколений объектов, дополнительных потоков для финализации объектов и управляемой кучи для обслуживания крупных объектов.
В главе также было показано, каким образом программно взаимодействовать со сборщиком мусора с применением класса
System.GC
. Как отмечалось, единственным случаем, когда может возникнуть необходимость в подобном взаимодействии, является построение финализируемых или освобождаемых классов, которые имеют дело с неуправляемыми ресурсами.
Вспомните, что финализируемые типы — это классы, которые предоставляют деструктор (переопределяя метод
Finalize()
)для очистки неуправляемых ресурсов во время сборки мусора. С другой стороны, освобождаемые объекты являются классами (или структурами не ref
), реализующими интерфейс IDisposable
, к которому пользователь объекта должен обращаться по завершении работы с ними. Наконец, вы изучили официальный шаблон освобождения, в котором смешаны оба подхода.
В заключение был рассмотрен обобщенный класс по имени
Lazy<>
. Вы узнали, что данный класс позволяет отложить создание затратных (в смысле потребления памяти) объектов до тех пор, пока вызывающая сторона действительно не затребует их. Класс Lazy<>
помогает сократить количество объектов, хранящихся в управляемой куче, и также обеспечивает создание затратных объектов только тогда, когда они действительно нужны в вызывающем коде.