Часть 1

Предисловие к части 2

Мне было приятно от того, как вы приняли первую часть. В этот раз я решил слегка разнообразить повествование иллюстрация от ChatGPT, но текст по-прежнему мой, поэтому я открыт вашим комментариям с орфографическими ошибками.

Несколько слов о ООП

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

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

21. Классы

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

💡Само название класса вашей программы ни на что не влияет, главное, что в нем содержится точка входа - static void Main.Но именно такое название классу дает среда разработки, когда вы создаете новый проект!

Итак, к нашему примеру с юнитом из игры. Все классы рекомендуется хранить в отдельных файлах, поэтому в обозревателе вашего проекта создайте новый класс (Ctrl + Shift + A) с названием Unit:

class Unit 
{
   public string Name;
   public int Attack;
   public int Armor;
}

Вернемся в наш класс Program и метод Main, где мы создадим экземпляр класса Unit:

Unit footman = new Unit();
footman.Name = "Пехотинец";
footman.Attack = 20;
footman.Armor = 2;

💡Если класс - это описание того функционала, которым обладает каждый его экземпляр, то экземпляр это уже готовый "представитель" класса. Точно таким же образом мы создавали объекты классов List, Dictionary и многих других!

📌Важно, что мы сделали поля класса (так называют переменные в теле класса) с ключевым словом public. Без него мы бы просто не могли извне задать им значения.

Конструкторы

Когда мы пишем

Тип переменная = new Тип();

Тип() похоже на вызов метод без аргументов, не так ли?

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

Каждый класс или структура имеют так называемый конструктор по умолчанию (он же - конструктор без параметров). Но если вы напишите для вашего типа конструктор с параметрами, то пользоваться конструктором по умолчанию больше не получится, если только вы его не создадите явно.

Пример с юнитом можно упростить так, чтобы задавать все параметры через конструктор:

class Unit 
{
   public string Name;
   public int Attack;
   public int Armor;

   public Unit(string name, int attack, int armor)
   {
      this.Name = name;
      this.Attack = attack;
      this.Armor = armor;
   }
}

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

Теперь можно упростить создание юнита:

Unit footman = new Unit("Пехотинец", 20, 2);

Да, возможность писать var у нас все так же есть, не беспокойтесь.

22. Поля только для чтения и константы

Поля могут быть доступны только для чтения, для этого пометить их модификатором readonly (дословно: только для чтения).

Допустим мы делаем класс настроек для игры и сразу определяем доступные языки интерфейса на выбор.

class GameSettings
{
   public readonly string[] AvailableLanguages = ["Russian", "English", "Chinese"];
}

💡У ссылочных коллекций (List, Dictionary и т.д.) все равно можно динамически добавлять или удалять элементы, поэтому readonly не позволит лишь перезаписать ссылку другой коллекцией. Такое поведение может быть неожиданным для начинающего разработчика, это важно иметь в виду. Однако, современный C# предоставляет специальные коллекции на случай, когда данные нужно сделать неизменяемыми: IReadOnlyList, ReadOnlyCollection, ImmutableArray.

Константы похожи на readonly поля, но означаю значение, известное на этапе компиляции. Это может быть значение примитивного типа.

class MathHelper
{
   public const double PI = 3.1415926535897931;
}

💡Именно такие константы предоставляет класс Math, в котором вы можете найти число Пи и число Эйлера в виде констант.

Все константы в месте их использования просто заменяются на свое значение. А еще константы нельзя пометить модификатором static (о нем будет позже), потому что неявным образом они уже являются статическими, т.е. не принадлежат экземпляру.

23. Свойства

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

Свойства же предоставляют больше контроля над доступом к этому самому полю.

В самом простом случае свойство имеет обычные модификаторы get и set, которые позволяют как получать значение (get), так и менять его (set).

Самое время переписать класс Unit с использованием свойств!

class Unit 
{
   public string Name { get; set; }
   public int Attack { get; set; }
   public int Armor { get; set; }

   public Unit(string name, int attack, int armor)
   {
      this.Name = name;
      this.Attack = attack;
      this.Armor = armor;
   }
}

А вот если бы мы хотели ввести минимальное и максимальное значения атаки, как бы нам стоило это сделать?

Мы можем ввести приватное поле _attack и проверять value при установке.

class Unit 
{
   private const int MinAttack = 1;
   private const int MaxAttack = 50;

   public string Name { get; set; }

   private int attack;
   public int Attack
   { 
      get => attack; 
      set 
      {
         if (value < MinAttack) attack = MinAttack;
         else if (value > MaxAttack) attack = MaxAttack;
         else _attack = value;
      } 
   }

   public int Armor { get; set; }

   public Unit(string name, int attack, int armor)
   {
      this.Name = name;
      this.Attack = attack;
      this.Armor = armor;
   }
}

Стрелочка (=>) возвращает значение из геттера, это очень удобно.

С field синтаксисом из C# 13 мы может еще больше упростить свойство Attack, не создавая приватного поля для этого:

public int Attack
{ 
   get => field; 
   set 
   {
      if (value < MinAttack) field = MinAttack;
      else if (value > MaxAttack) field = MaxAttack;
      else field = value;
   } 
}

Компилятор просто создаст необходимое поле за нас, а мы застрахованы от ошибки, потому что field всегда будет равносильно этому полю в рамках свойства.

А еще свойства могут быть вычисляемыми! Для примера создадим класс прямоугольника с длинной и шириной, а вот площадь будет вычислять из них.

class Rectangle
{
   public int Width { get; set; }
   public int Height { get; set; }
   public int Area => Width * Height;

   public Rectangle(int width, int height)
   {
      this.Width = width;
      this.Height = height;
   }
}

Теперь мы можем работать с классом Rectangle так:

var rect = new Rectangle(2, 3);
Console.WriteLine(rect.Area); // 6
rect.Width = 10;
Console.WriteLine(rect.Area); // 30

🙋Задание: добавить классу юнит два свойства: Health для здоровья и IsAlive - автоматическое, которое будет возвращать true, если здоровье больше нуля.

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

На самом деле мы уже работали со статическими классами: Console, Convert, Math и т.д. Их отличает то, что не нужно создавать экземпляр класса для работы с ними.

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

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

static class ArrayPrinter
{
   public static void PrintInt32(int[] arr)
   {
      foreach (int num in arr)
      {
         Console.WriteLine(num);
      }
   }
}

Теперь мы можем работать с ним:

int myNums = [8, 16, 25, 55, 100];
ArrayPrinter.PrintInt32(myNums);

25. Структуры

Со структурами мы тоже уже сталкивались, такой имеют числа, логическое значение и многие другие типы. Да, это те самые значимые типы.

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

Создадим тип вектора в двухмерном пространстве, прям как в Unity:

struct Vector2
{
   public float X;
   public float Y;

   public Vector2(float x, float y)
   {
      this.X = x;
      this.Y = y;
   }
}

Добавим в структуру статический метод расстояния между двумя векторами

struct Vector2
{
   public float X;
   public float Y;

   public Vector2(float x, float y)
   {
      this.X = x;
      this.Y = y;
   }

   public static float DistanceBetween(Vector2 a, Vector2 b)
   {
      float dx = a.X - b.X;
      float dy = a.Y - b.Y;
      return (float)Math.Sqrt(dx  dx + dy  dy);
   }
}

Теперь мы можем писать так:

Vector2 v1 = new Vector2(10, 20);
Vector2 v2 = new Vector2(5, 0);
float dist = Vector2.DistanceBetween(v1, v2); // 21.54...

ReadOnly структуры

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

Начиная с C# 8 появились readonly структуры. Все их члены должны быть помечены модификатором readonly.

Перепишем прошлую структуру вектора с учетом readonly: 

readonly struct Vector2
{
   public readonly float X;
   public readonly float Y;

   public Vector2(float x, float y)
   {
      this.X = x;
      this.Y = y;
   }

   public static float DistanceBetween(Vector2 a, Vector2 b)
   {
      float dx = a.X - b.X;
      float dy = a.Y - b.Y;
      return (float)Math.Sqrt(dx  dx + dy  dy);
   }
}

Для обеспечения иммутабельности вместо изменения данных, мы будем возвращать новую структуру (как с DateTime). Чтобы проиллюстрировать это создадим метод Add для сложения векторов.

readonly struct Vector2
{
   public readonly float X;
   public readonly float Y;

   public Vector2(float x, float y)
   {
      this.X = x;
      this.Y = y;
   }

   public Vector2 Add(Vector2 b)
   {
      return new Vector2(this.X + b.X, this.Y + b.Y);
   }

   public static float DistanceBetween(Vector2 a, Vector2 b)
   {
      float dx = a.X - b.X;
      float dy = a.Y - b.Y;
      return (float)Math.Sqrt(dx  dx + dy  dy);
   }
}

🙋Задание: сделайте аналогичный метод Mul для умножения вектора на вектор.

26. Инкапсуляция

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

Модификаторы доступа

Названия всех модификаторов доступа говорит само за себя:

public - доступ отовсюду извне

internal - доступ отовсюду внутри сборки

file - доступ отовсюду внутри файла, в котором описан

protected - доступ для типа и его наследников

private - доступ только для типа

Сборкой (assembly) можно считать вашу программу, будь она консольным приложением или библиотекой. В больших программах часто используется множество проектов, а следовательно - множество сборок.

💡В большинстве случаев новичкам хватает public и private. Но если представить, что вы разрабатываете библиотеку, то вам бы захотелось скрыть часть "служебных" и вспомогательных классов от конечного пользователя. Таким образом вы бы сами пришли к модификатору internal, сделав классы недоступными за пределами вашей сборки.

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

Например, в стратегии в духе WarCraft 3, мы можем создавать юнита для нужного игрока, но юниты вроде торговцев создаются только для нейтрального игрока. Таких деталей может быть много.

class Unit
{
   private Unit(string name, string attack, string armor, Player owner)
   {
      // заполнение свойств
   }

   public static Unit CreateWarrior(Player owner)
   {
      // создаем воина для нужного игрока
   }

   public static Unit CreateArcher(Player owner)
   {
      // создаем воина для нужного игрока
   }

   public static Unit CreateNpc()
   {
      // создаем NPC для нейтрального игрока
   }
}

Создадим нашего юнита:

var warrior = Unit.CreateWarrior(currentPlayer);

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

27. Наследование

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

class ParentClass
{

}

class ChildClass : ParentClass
{

}

 Как теперь ChildClass связан с ParentClass? Их связь можно описать так: ChildClass является ParentClass. Подробнее об этом будет в следующей главе.

Наследование образует четкую иерархию классов, поэтому нужно четко понимать, какие именно сущности должны быть связаны! Это важно еще и потому, что класс может наследоваться только от одного класса. Как же быть? Умные люди задолго до меня сформировали важный принцип, который звучит примерно так: объекты базовых классов должны быть заменяемы объектами производных классов без изменения ожидаемого поведения программы.

Дочерний класс перенимает все члены родительского класса. Приведу пример с оружием в FPS игре в духе Borderlands. Есть обычная "пушка", а есть элитная, с шансом критической атаки и множителем для вычисления критического урона.

class Gun 
{
   public int BulletCount { get; set; }
   public float Damage { get; set; }
}

class RareGun : Gun
{
   public float CriticalChance { get; set; }
   public float CriticalMultiplier { get; set; }
}

Ключевое слово base

Самый лучший пример наследования из игр - это конечно иерархия игровых персонажей и юнитов в играх различного жанра. Вспомним наш класс Unit:

class Unit 
{
   public string Name { get; set; }
   public int Attack { get; set; }
   public int Armor { get; set; }

   public Unit(string name, int attack, int armor)
   {
      this.Name = name;
      this.Attack = attack;
      this.Armor = armor;
   }
}

Допустим мы хотим создать на основе класса Unit новый класс - Hero. При этом мы не намерены ничего менять в родительском классе. Сейчас проблемой является конструктор класса Unit. Как это сделать?

class Hero : Unit
{
   public int Strength { get; set; }
   public int Agility { get; set; }
   public int Intelligence { get; set; }

   public Hero(string name, int attack, int armor, int str, int agi, int intl) : base(name, attack, armor)
   {
      this.Strength = str;
      this.Agility = agi;
      this.Intelligence = intl;
   }
}

Когда нужно обратиться к членам родительского класса, например, вызывать конструктор родительского класса, можно использовать ключевое слово base.

Запечатанные классы

Если нам важно запретить наследование от класса, мы должны пометить его модификатором sealed.

sealed class MySecretClass
{
   // тело класса
}

При попытке унаследовать запечатанный класс, среда разработки заботливо подсветит красным ошибку в этом месте, сообщив, что от таких классов наследоваться нельзя!

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

28. Виртуальные методы

Если пометить метод ключевым словом virtual, наследники при желании смогут переопределить этот метод.

Определим виртуальный метод для класса Unit

class Unit 
{
   public string Name;
   public int Attack;
   public int Armor;

   public Unit(string name, int attack, int armor)
   {
      this.Name = name;
      this.Attack = attack;
      this.Armor = armor;
   }

   public virtual string GetDescription()
   {
      return $"{this.Name} ATK:{this.Attack} ARM:{this.Armor}";
   }
}

Переопределим этот метод для героев:

class Hero : Unit
{
   public int Strength;
   public int Agility;
   public int Intelligence;

   public Hero(string name, int attack, int armor, int str, int agi, int intl) : base(name, attack, armor)
   {
      this.Strength = str;
      this.Agility = agi;
      this.Intelligence = intl;
   }

   public override string GetDescription()
   {
      return $"{this.Name} ATK:{this.Attack} ARM:{this.Armor} STR:{this.Strength} AGI:{this.Agility} INT:{this.Intelligence}";
   }
}

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

Переопределение методов Object

Методы ToString, GetHashCode и Equals, доступные всем типам от их "родителя", Object, можно переопределить таким же образом, как в примере с GetDescription.

Косвенно вы уже сталкивались с работой метода ToString. Если попытаться передать в Console.WriteLine объект любого созданного вами типа, то вы увидите, что вывод в консоли представлен просто названием вашего класса. Чтобы изменить ситуацию, нужно переопределить метод ToString, чтобы он показывал только то, что нужно нам! 

Проиллюстрируем пример с нашей структурой Vector2, сделав для нее удобное отображение:

readonly struct Vector2
{
   public readonly float X;
   public readonly float Y;

   public Vector2(float x, float y)
   {
      this.X = x;
      this.Y = y;
   }

   public Vector2 Add(Vector2 b)
   {
      return new Vector2(this.X + b.X, this.Y + b.Y);
   }

   public static float DistanceBetween(Vector2 a, Vector2 b)
   {
      float dx = a.X - b.X;
      float dy = a.Y - b.Y;
      return (float)Math.Sqrt(dx  dx + dy  dy);
   }

   public override string ToString()
   {
      return $"Vector2[{this.X},{this.Y}]";
   }
}

Теперь мы можем проверить работу ToString:

var v2 = new Vector2(15, 25);
Console.WriteLine(v2); // Vector2[15,25]

29. Полиморфизм

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

Unit[] player1Units = [
   new Unit("Скелет", 15, 0), 
   new Hero("Лич", 25, 1, 18, 14, 19)
];
foreach (Unit u in player1Units)
{
   Console.WriteLine(u.GetDescription());
}

Полиморфизм прекрасно раскрывается с интерфейсами и абстрактными классами, об этом дальше.

30. Абстрактные классы

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

Абстрактные классы нужны, когда есть общая логика и общее состояние, но реализация должна быть разной.

Создадим абстрактный класс игрового объекта в духе Unity:

abstract class GameObject 
{
   public abstract void Update();
}

class Cube : GameObject
{
   public float WorldX;
   public float WorldY;
   public float WorldZ;

   public override void Update()
   {
      Console.WriteLine("Рисуем куб на сцене");
   }
}

class Label : GameObject
{
   public float ScreenX;
   public float ScreenY;
   public string Text;

   public override void Update()
   {
      Console.WriteLine("Рисуем надпись на экране");
   }
}

💡В WarCraft 3 похожим образом представлена иерархия игровых объектов: в роли родительского класса выступает widget, от которого наследуются unit, item (предмет), destructible (разрушаемый объект). Каждый из них имеет здоровье, позицию в мире и т.д.

31. Интерфейсы

Интерфейсы - это такие контракты, которые определяют публичные свойства или методы, которыми должны обладать их наследники. Интерфейсы принято называть с заглавной i (I от слова Interface), чтобы отличить их от классов.

Например, в играх часто можно увидеть подобный интерфейс:

interface IMovable
{
   float Speed { get; set; }
   void Move(float x, float y);
}

Таким образом, мы можем в самых разных классах (Unit, Missile, Hero) гарантировать способность к передвижению.

Пример наследования такого интерфейса:

class Missile : IMovable 
{
   public float Speed { get; set; }

   public void Move(float x, float y) 
   {
      // наша логика движения героя
   }
}

При наследовании интерфейса нужно обязательно реализовать методы и свойства, которые он определяет (причем, как public!), иначе среда разработки явно укажет на ошибку.

Теперь мы можем передавать экземпляр Missile в те методы, которые могут принимать IMovable объекты (да, полиморфизм!). Допустим, это некий класс физики в духе Unity, который заправляет движением объектов и воздействием на них различных сил:

static class Physics
{
   public static void StopObject(IMovable movable)
   {
      movable.Speed = 0;
   }
}

🤠Существует давний спор, мол, зачем нужны интерфейсы, если есть абстрактные классы? В C# он решается очень просто: 

  1. Можно наследовать только от одного класса (в отличии от C++), но сколько угодно интерфейсов, поэтому разница фундаментальна

  2. Структуры не могут наследоваться от классов, в том числе от абстрактных. Но могут наследовать интерфейсы

Поэтому в C# интерфейсы и абстрактные классы решают разные задачи, и на практике выбор между ними обычно очевиден.

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

interface ILogger
{
   void WriteLog(string text);
}

class MyBusiness
{
   private ILogger _logger;

   public MyBusiness(ILogger logger)
   {
      // сохраняем логгер для дальнейшего использования
      _logger = logger;
      // прочая логика...
   }
}

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

32. Сопоставление с шаблоном

Оператор is

Этот оператор отвечает на вопрос является ли объект тем, с чем его сравнивают.

object strObj = "строка в объекте";
if (strObj is string)
{
   Console.WriteLine("strObj имеет тип string"):
}

Этот оператор особенный еще и тем, что в отличии от оператора сравнения (==), который может быть перегружен, логика is всегда одинаковая и не может быть изменена разработчиком.

Другая интересная возможность состоит в возможности определить переменную прямо в блоке is:

if (strObj is string str)
{
   Console.WriteLine("Значение strObj = " + str):
}

Это слегка напоминает методы TryParse: если условие is было выполнено (т.е. strObj является строкой, то его строковое значение можно будет использовать через переменную str).

Оператор not

Этот оператор комбинируется с оператором is и похож на оператор неравенства (!=).

if (strObj is not int) Console.WriteLine("Объект не является числом!");

Операторы and и or

Оператор and похож на логический оператор И (&&), а or в свою очередь похож на логический оператор ИЛИ (||). В чем же различия? Комбинируя and и or с оператором is, вы можете писать более компактные условия.

int x = int.Parse(Console.ReadLine());
if (x is 5 or 10) Console.WriteLine("Это 5 или 10");

Но важно не попасться в ловушку, используя оператор not:

if (x is not 5 or 10) Console.WriteLine("Это не 5 или не 10");

Да, эта же конструкция через обычные операторы сравнения была бы представлена так:

if ((x != 5) || (x != 10)) Console.WriteLine("Это не 5 или не 10");

Хотя можно было ожидать, что not применяется только к первому оператору (т.е. это не 5 или это 10).

Но это все слегка абстрактные примеры, давайте раскроем мощность этих операторов на реальных примерах. Как я вам говорил, в WarCraft 3 используется примерно такая иерархия объектов:

abstract class Widget
{
   public int Name { get; set; }
   public int Life { get; set; }
}

class Unit : Widget
{
   public Unit(string name)
   {
      this.Name = name;
   }
}

class Item : Widget
{
   public Item(string name)
   {
      this.Name = name;
   }
}

class Destructible : Widget
{
   public Destructible(string name)
   {
      this.Name = name;
   }
}

В реальной игре у них куда больше свойств и отличий между собой, но нам сейчас это не важно.

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

Widget[] GetWidgetsInMap()
{
   return [ 
      new Unit("Пехотинец"), 
      new Item("Посох мага"), 
      new Destructible("Бочка") ];
}

А теперь разберем результат через наши операторы:

foreach (Widget widget in GetWidgetsInMap())
{
   if (widget is Unit unit) 
   {
      Console.WriteLine("Найден юнит " + unit.Name);
   }
   else if (widget is Item item) 
   {
      Console.WriteLine("Найден предмет " + item.Name);
   }
   else if (widget is Destructible destruct) 
   {
      Console.WriteLine("Найден разрушаемый объект " + destruct.Name);
   }
}

33. Перечисления

Перечисления являются возможностью хранить числовые константы в организованных группах.

Например, мы хотим дать игроку возможность выбора цвета команды в духе WarCraft 3:

enum PlayerColors
{
   Red,
   Blue,
   Green,
   Yellow,
   Violet
}

По сути, каждое значение здесь лишь константа целочисленного типа. Но за счет объединения в общее перечисление PlayerColors, мы можем гарантировать, что, например, метод можно будет вызвать только одним из допустимых зн��чений.

static class GameManager
{
   public static void InitPlayer(string playerName, PlayerColors color)
   {
      Console.WriteLine($"Игроком {playerName} выбран цвет {color}");
   }
}

Вызовем метод с красным цветом игрока:

GameManager.InitPlayer("Player1", PlayerColors.Red);

Обратите внимание на передачу Red. Мы обращаемся к перечислению и через точку указываем выбранное значение перечисления. Это похоже на статический класс, в котором есть константы:

static class PlayerColors
{
   public const int Red = 0;
   public const int Blue = 1;
   public const int Green = 2;
   public const int Yellow = 3;
   public const int Violet = 4;
}

По сути, enum является упрощением для таких статических классов, превращает их в единый тип, что обеспечивает типобезопасность (ведь мы передаем не int, а PlayerColors), а также автоматически нумерует константы.

💡Кстати, тип перечисления можно задать вручную. Для этого нужно использовать синтаксис наследования (: тип). Чаще всего используют int или byte

Мы так же вольны нумеровать константы самостоятельно или указать некоторые индексы:

enum HeroTypes
{
   Warrior = 1,
   Mage,
   Rogue
}

В таком случае Mage получит значение 2, а Rogue - значение 3.

Битовые флаги

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

Во многих "рогаликах" есть монстры-чемпионы, которые отличаются бонусами к характеристикам или аурами (как в Diablo). Рассмотрим пример для игры в духе The Binding Of Isaac, где чемпионы могут иметь одно или несколько свойств вида: быстрое движение, регенерация, автоматическая стрельба, взрыв после смерти и защищенность:

[Flags]
enum ChempionBeheviours
{
   FastMoving = 1,
   Regeneration = 2,
   Turret = 4,
   Explosive = 8,
   Defensive = 16
}

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

Флаги комбинируются через битовое или (|):

var behaviours = ChempionBeheviours.FastMoving | ChempionBeheviours.Explosive;

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

Чтобы проверить наличие флага, нужно использовать метод HasFlag класса Enum.

🙋Задание: попробуйте самостоятельно создать перечисление с характеристиками бонусов предмета из RPG (характеристики и т.д.).

34. Закрепление темы

🙋 Что такое ООП и для чего оно нужно?

🙋 Есть следующие классы: холодное оружие, огнестрельное оружие, оружие, навык "Критический удар", навык, игрок. Подумайте, в каком случае и как классы можно связать наследованием, а в каком нет. Объясните почему вы так считаете.

🙋Дополните класс юнита методом для атаки. Выводите в консоль сообщение вида "юнит X атаковал юнита Y и нанес Z урона", а так же выводите текст о смерти юнита, если юнит погиб. Объясните, почему вы выбрали именно такой подход.