Основы C#
October 25, 2022

Классы и объекты

Привет! В данной статье мы продолжим разбираться в особенностях устройства основных механизмов классов в C#, немного пофилософствуем о жизненном цикле объектов и сборщике мусора, а также узнаем о типах перечислений.

Шаблоны проектирования

В программировании существует множество типовых проблем, подходы решения к которым были придуманы нашими коллегами. Способы решения таких проблем называются шаблонами проектирования или паттернами (от англ. pattern – шаблон). По мере овладения синтаксисом языка и прокачки своего скилла вы все чаще будете встречаться с необходимостью решать типовые задачи и писать читаемый и поддерживаемый код, решение данных задач может быть осуществлено с помощью реализации существующих общепринятых в сообществе подходов. Такое решение может быть эффективно, а код удобен в дальнейшей поддержке и использовании.

Методы доступа к данным класса

Одним из общепринятых подходов организации механизмов чтения и записи полей является реализация методов доступа, часто именуемых аксессорами (от access – доступ):

  • getter (get – получать) – общее именование методов доступа, позволяющих получать данные;
  • setter (set – задавать) – общее именование методов доступа, позволяющих задавать данные.

Простыми словами: идея данного подхода заключается в том, что при сокрытии от посторонних глаз полей модификатором private мы вручную определяем поведение при работе с ними через методы, сохраняя таким образом четкий контроль состояния полей объекта. Более сложно: это предпочтительный метод в объектно-ориентированном программировании, позволяющий достичь необходимый уровень абстракции и инкапсуляции, более подробно об этом мы обязательно поговорим в дальнейших статьях.

class Human 
{ 
    private string name;
    private int age; 
    
    public string GetName() 
    { 
        return name; 
    } 
    
    public void SetName(string nameToSet) 
    { 
        name = nameToSet; 
    } 
    
    public int GetAge() 
    { 
        return age; 
    } 
    
    public void SetAge(int ageToSet) 
    { 
        age = ageToSet; 
    } 
}

В представленном примере у нас есть класс человечка, хранящий имя и возраст в виде приватных полей. Для организации доступа к данным полям создаем методы доступа, позволяющие получать и задавать значения полей. Пример совсем простой, но нужно понять, что такой подход позволит нам задать дополнительную логику обработке входных данных, обеспечивая контроль доступа.

Свойства классов

Одним из множества приятных плюсов C# является наличие различного синтаксического сахара. Синтаксический сахар - синтаксические возможности языка, которые дублируют в себе функционал уже имеющихся синтаксических конструкций, но являются более короткими аналогами и преобразуются на этапе компиляции в эти же самые конструкции, позволяя тем самым более быстро писать понятный код.

В предыдущей статье мы познакомились с основными членами класса: полями и методами. Свойство класса — это специальный член класса, предоставляющий гибкий механизм для чтения, записи и вычислений значения поля, сочетая в себе одновременно возможности поля класса и методов доступа к ним. Свойства являются синтаксическим сахаром.

Общий шаблон объявления свойств выглядит следующим образом:

[модификатор доступа] [тип] ИмяСвойства
{
    get { инструкции, выполняемые для возвращения значения }
    set { инструкции, выполняемые для установки значения }
}

Разберемся на примере:

class Human 
{ 
    private string name; 
    private int age; 
    
    public string Name 
    { 
        get { return name; } 
        set { name = value; } 
    } 
    
    public int Age 
    { 
        get { return age; }  
        set { age = value; } 
    }
}

Сравните получившуюся конструкцию с предыдущем решением. Выглядит гораздо компактнее, не правда ли? Для наших полей имени и возраста мы создали свойства, именуемые согласно кодстайлу с большой буквы. Для свойств определяем модификатор доступа, тип. Внутри с помощью аксессоров get и set определяем логику получения и задавания данных:

  • в блоке get определяем действия для получения значения свойства, с помощью оператора return возвращаем какое-то значение (тип возвращаемого значения должен совпадать с типом свойства);
  • в блоке set устанавливаем значение свойства, с помощью параметра value обозначается переданное свойству значение (т.е. данным ключевым словом обозначается параметр, значение которого передается при присваивании свойству).

Наше свойство является посредником между нашими скрытыми полями и внешним кодом.

Обращение со свойствами класса осуществляется с тем же синтаксисом, что и с обычными полями класса:

Human human = new Human(); 
human.Name = "Vasya";    // присваиваем значение свойству 
human.Age = 23;          // и здесь

Console.WriteLine(human.Name); // Vasya 
Console.WriteLine(human.Age);  // 23

Присваивание значения свойству приводит к вызову аксессора set, значение передается через value. Возвращение свойства приводит к вызову аксессора get и возврату значения с помощью return.

Автоматические свойства

И здесь наш любимый C# подкидывает нам еще больше синтаксического сахара. В случае, когда в методах доступа не требуется определение дополнительной логики (как в нашем примере), мы можем прибегнуть к использованию автоматически реализуемых свойств или просто авто свойств.

class Human 
{ 
    public string Name { get; set; } 
    public int Age { get; set; } 
}

При использовании таких свойств не нужно прописывать поля, с которыми будет работать свойства и описывать базовую логику присваивания и возврата в теле set и get. Компилятор на этапе компиляции создаст из свойства соответствующие приватные поля и методы для обращения к этим полям, а наш код маленьким и приятным для глаза)

Конструкторы классов

В статье "Классы, поля и методы", обсуждая способ создания классов и работу оператора new, было отмечено, что завершающим действием данного оператора является вызов конструктора экземпляра. Так что же такое конструктор?

Конструктор – это специальный метод, вызываемый при создании объекта класса. Он используется для инициализации полей некоторыми значениями, выполнения какой-либо логики при создании объекта. Конструктор позволяет создавать 100% пригодный экземпляр класса, чтобы мы не имели чемодан без ручки. Конструктор имеет название, совпадающее с именем класса, и не имеет типа:

[модификатор доступа] ИмяКласса 
{ 
    необходимые инструкции 
}

Существует два типа конструкторов: конструктор по умолчанию и параметризованный конструктор.

Под конструктором по умолчанию понимают конструктор без параметров, инициализирующий все поля класса значениями по умолчанию. В случае, когда мы явно не создаем никакого конструктора в коде, компилятор генерирует конструктор по умолчанию за нас:

class Human
{
    public Human()
    {
    
    }
}

Конструктор по умолчанию не генерируется в случае объявления собственных конструкторов.

Под параметризованным конструктором понимают конструктор, имеющий как минимум один параметр:

class Human
{
    public Human(string name, int age)
    {
    
    }
}

Класс может иметь неограниченное число конструкторов, но стоит помнить, что при их объявлении важна последовательность типов параметров (но НЕ их имена), такая последовательность обязана быть уникальна, иначе мы получим ошибку:

class Human 
{ 
    public Human() // корректный конструктор 
    { 
    } 
    
    public Human(string name) // корректный конструктор 
    { 
    } 
    
    public Human(int age, string name) // корректный конструктор 
    { 
    } 
    
    public Human(string name, int age) // корректный конструктор 
    { 
    } 
    
    public Human(int age)    // Ошибка! 
    {                        // Порядок типов параметров данных 
    }                        // конструкторов совпадает
    public Human(int height) // Это два одинаковых конструктора! 
    { 
    } 
}

Инструкции, приведенные внутри тела конструктора, могут выполнять различные задачи: инициализация и проверка валидности полей, вывод вспомогательных сообщений и многое другое, но выполняться они будут единожды для каждого экземпляра класса – в момент его создания.

class Human 
{ 
    public string Name { get; set; } 
    public int Age { get; set; } 
    
    public Human() 
    { 
        Name = "Default guy"; 
        Age = 20; 
        Console.WriteLine("Default guy is created"); 
    } 
    
    public Human(string name, int age) 
    { 
        Name = name; 
        Age = age; 
        Console.WriteLine("Human is created"); 
    } 
}
Human defaultHuman = new Human(); // вывод: "Default guy is created"
Console.WriteLine(defaultHuman.Name); // вывод: "Default guy"
Console.WriteLine(defaultHuman.Age); // вывод: 20

Human human = new Human("Vasya", 50); // вывод: "Human is created"
Console.WriteLine(human.Name); // вывод: "Vasya"
Console.WriteLine(human.Age); // вывод: 50

При создании новых экземпляров класса Human с помощью оператора new, мы используем конструкторы, инициализируя поля объекта и выводя вспомогательное сообщение, оповещающее о создании человека.

Жизненный цикл объекта, сборка мусора и деструктор

Процесс создания объекта называют инстанцированием (instance – экземпляр). После создания, объект располагается где-то в куче, в оперативной памяти. В общем виде принято считать, что объект жив, пока хотя бы какая-нибудь переменная хранит адрес этого объекта, ссылается на него. Т.е. пока мы имеем возможность взаимодействовать с объектом – объект жив. Как только теряются все ссылки на объект, он становится недостижим для нас. Этот объект попрежнему существует, занимает какое-то место в памяти, но он бесполезен. Проще говоря данный объект является мусором, он лишь занимает наше драгоценное место в памяти. Именно поэтому все объекты, затерянные в виртуальной машине, помечаются как мусор.

Human human1 = new Human("Oleg");
human1 = null;

Human human2 = new Human("Vanya");

Human human3 = new Human("Sanya");
human3 = human2;

Рассмотрим поподробнее пример выше. Мы создали 3 объекта класса человека, получили трех приличных членов общества: Олега, Ваню и Саню. Ссылку на Олега хранит лишь одна переменная – human1. Сразу после инстанцирования Олега, мы занулили ссылку, указывающую на него, отрезав таким образом для себя связь с ним. Press F Олегу, нам больше до него не добраться:( Ссылок, указывающих на него больше нет, именно поэтому бедолага будет помечен как мусор. Затем после создания объектов Ваня и Саня, мы присваиваем ссылке human3 значение ссылки human2. Таким образом, мы получаем две ссылки (human2 и human3), ссылающиеся на один объект – Ваню. На Саню же теперь не указывает ни одна ссылка, он тоже становится бесполезен и помечается как мусор.

Все бесполезные объекты, помеченные как мусор, рано или поздно удаляются. Здесь в дело вступает сборщик мусора или garbage collector. Сборщик мусора – специальный механизм, который отвечает за процесс управления памятью виртуальной машины CLR. Периодически запускаемый процесс сборки мусора удаляет бесполезные неиспользуемые объекты, освобождая таким образом место для новых. Сборщик мусора запускается НЕ сразу после потери всех ссылок на объект, такие мусорные объекты будут продолжать занимать память, а CLR сама определит когда их необходимо удалить, например, когда будет подходить к концу свободное место.

Доходя до бесполезных объектов, сборщик мусора вызывает у них специальный метод, который отвечает за уничтожение этого экземпляра класса. Такой специальный метод называется деструктор. Если конструктор вызывается при создании экземпляра класса, то деструктор вызывается при его уничтожении, все предельно логично. Каждый класс может иметь только 1 деструктор, вызывать его напрямую невозможно, он запускается автоматически. Деструктор аналогично конструктору имеет реализацию по умолчанию, которую при желании мы можем модифицировать. Для этого необходимо воспользоваться данной конструкцией:

~ИмяКласса() 
{ 
    тело деструктора 
}

Так, например, экземпляры нашего класса Human, могут прощаться с нами при уничтожении:

~Human()
{
    Console.WriteLine("Bye bye");
}

Если в теле деструктора указаны какие-либо инструкции, препятствующие корректному уничтожению объекта, то объект все равно будет удален с помощью вызова базовой реализации деструктора, а наши инструкции проигнорируются.

При завершении программы происходит полное высвобождение ресурсов, и все наши объекты удаляются.

Статические классы

Что делать в случаях, когда нам вообще не нужен экземпляр класса, чтобы реализовать необходимый функционал? Для решения подобных задач существуют статические классы. Создание таких классов происходит точно так же, как и обычных, только с добавлением ключевого слова static:

[Модификатор доступа] static class Имя_класса
{
    тело класса
}

Мы уже неоднократно показывали статические классы в нашем коде – класс Console является статическим. Статический класс позволяет удобно выносить общий функционал, который может быть использован в любой части программы.

Особенности статического класса:

  • статические класс содержит только статические элементы;
  • статический класс не может содержать обычные конструкторы класса;
  • экземпляр статического класса невозможно создать;
  • статический класс не может быть родительским (более подробно об этом мы обязательно поговорим в следующих статьях).

Обращение к элементам статического класса происходит через имя класса и оператор точки:

Console.WriteLine();

В данном примере мы обратились к статическому классу Console и вызвали статический метод WriteLine().

В общем случае мы не можем сказать, когда именно тот или иной класс будет загружен в память, но гарантировано, что класс будет загружен к моменту первого обращения к нему в программе, при этом будут загружены и его статические члены.

Статические конструкторы

Но что если мы хотим проинициализировать статические члены любого класса или единожды выполнить какие-то действия до момента создания первого экземпляра нестатического класса? Для решения этой проблемы существуют статические конструкторы. Статический конструктор используется для инициализации любых статических элементов или для единовременного выполнения какого-либо действия. Статический конструктор вызывается автоматически при первом обращении к статическим членам класса или перед созданием первого экземпляра класса.

static ИмяКласса() // статический конструктор
{

}

Для каждого класса неявно по умолчанию генерируется статический конструктор. Определяя собственный конструктор, мы на самом деле модифицируем дефолтный, добавляя в него дополнительные инструкции.

Важные замечания:

  • статический конструктор не имеет модификаторов доступа и не принимает аргументы;
  • статический конструктор нельзя вызывать напрямую, он вызывается автоматически при первом обращении к статическим членам класса или перед созданием первого экземпляра класса;
  • статический конструктор отрабатывает единожды для класса;
  • статический конструктор обязан быть один.

Типы перечислений

Тип перечисления – это тип значений, который определяется набором именованных констант целочисленного типа. Объявление перечисления происходит с помощью ключевого слова enum и перечня значений:

[Модификатор доступа] enum ИмяПеречисления
{
    значение1,
    значение2,
    значение3,
    ...
    значениеN
}

Данный тип может быть очень удобен в случаях, когда мы имеем дело с заведомо известным числом состояний объекта. Так, например, мы можем представить дни недели в качестве типа перечислений:

public enum Day
{
     Monday,
     Tuesday,
     Wednesday,
     Thursday,
     Friday,
     Saturday,
     Sunday
}

Мы определили свой первый тип перечислений, состоящий из 7 значений – 7 дней недели. Обращение к значениям перечисления осуществляется с помощью оператора точки, подобно обращению к полям класса. Поскольку перечисления являются типом данных (хоть и примитивным), мы можем создавать и использовать переменные их типа, присваивать им соответствующие значения, а также передавать их в качестве аргументов при вызове конструкторов, методов и других элементов C#.

Day someDay = Day.Monday; // переменная типа перечислений Day содержит
                          // значение Monday
if(someDay == Day.Monday)
{
    Console.WriteLine("Today is Monday");
}

Внимательный читатель заметил в определении типа перечислений упоминание о том, что значения являются именованными константами целочисленного типа. Это означает, что каждая константа перечисления сопоставляется с некоторым целочисленным числом. Если мы явным образом ничего не указываем, то по умолчанию используется тип int, первому значению сопоставляется число 0, а каждому последующему значению сопоставляется число с шагом 1 (т.е. для второго значения: 1, для третьего значения: 2, для n-го значения: n - 1). Желанный целочисленный тип можно указывать явно с помощью двоеточия после имени перечисления (именно целочисленный тип!), а желанное целочисленное число, сопоставляемое со значением, с помощью оператора присваивания после конкретного значения:

 public enum Day : long // явно указали тип long
 {
     Monday,        // Monday сопоставляется с 0 по умолчанию
     Tuesday,       // Tuesday сопоставляется с 1 по умолчанию      
     Wednesday = 5, // Wednesday сопоставляется с 5 из-за явного указания
     Thursday,      // Thursday сопоставляется с 6 по умолчанию
     Friday = 103,  // Friday сопоставляется с 103 из-за явного указания
     Saturday,      // Saturday сопоставляется с 104 по умолчанию
     Sunday         // Sunday сопоставляется с 105 по умолчанию
 }

Каждому элементу перечисления можно задать значения, эти значения могут повторяться и даже являться другими элементами перечисления:

public enum Day
{
     Monday = 1,         // Monday сопоставляется с 1
     Tuesday = 43,       // Tuesday сопоставляется с 43
     Wednesday = Monday, // Wednesday сопоставляется с 1
     Thursday,           // Thursday сопоставляется с 2
     Friday = 23,        // Friday сопоставляется с 23
     Saturday = 23,      // Saturday сопоставляется с 23
     Sunday              // Sunday сопоставляется с 24
}

Чтобы использовать целочисленное значения элемента перечисления, мы можем использовать операцию явного приведения:

Console.WriteLine((int)Day.Tuesday); // для последнего примера Day
                                     // значение будет 43

Здесь стоит отметить, что константа сопоставляется с каким-то целочисленным значением, но мы не можем присвоить ей числовое значение:

Day monday = 1; // Ошибка!

Удобным случаем применения перечисления зачастую является связка с конструкцией switch case, разобранной в предыдущей статье. Предположим, мы хотим создать метод, который на основании переданного значения типа перечисления, приветствует нас и говорит о текущем дне недели:

public void SayHello(Day currentDay)
{
    switch (currentDay)
    {
        case Day.Monday:
            Console.WriteLine("Hi! Today is Monday.");
            break;
        case Day.Tuesday:
            Console.WriteLine("Hi! Today is Tuesday.");
            break;
        case Day.Wednesday:
            Console.WriteLine("Hi! Today is Wednesday.");
            break;
        case Day.Thursday:
            Console.WriteLine("Hi! Today is Thursday.");
            break;
        case Day.Friday:
            Console.WriteLine("Hi! Today is Friday.");
            break;
        case Day.Saturday:
            Console.WriteLine("Hi! Today is Saturday.");
            break;
        case Day.Sunday:
            Console.WriteLine("Hi! Today is Sunday.");
            break;
        default:
            break;
    }
}

Таким образом, заранее зная все дни недели, мы получили однозначно читаемый код, снизили вероятность получить ошибку из-за опечатки путем строгого ограничения работоспособности метода на типе перечисления.


На этом все, что было запланировано на эту статью. Далее разберем тему с массивов и начнем знакомиться с коллекциями. Ставьте лойсы под постом, подписывайтесь на наш несерьезный канал в телеграме. Всем хорошего настроения и продуктивного изучения самого лучшего языка!