Как стать автором
Обновить

Наследование, композиция, агрегация

Время на прочтение12 мин
Количество просмотров334K
Нередко случается, что решив разобраться с какой-то новой темой, понятием, инструментом программирования, я читаю одну за другой статьи на различных сайтах в интернете. И, если тема сложная, то эти статьи могут не на шаг не приблизить меня к понимаю. И вдруг встречается статья, которая моментально дает озарение и все паззлы складываются воедино. Трудно определить, что отличает такую статью от других. Правильно подобранные слова, оптимальная логика изложения или же просто более релевантный пример. Я не претендую на то, что моя статься окажется новым словом в C# или же лучшей обучающей статьей. Но, возможно для кого-то она станет именно той, которая позволит разобраться, запомнить и начать правильно применять те понятия, о которых пойдет речь.

В объектно-ориентированных языках программирования существует три способа организации взаимодействия между классами. Наследование — это когда класс-наследник имеет все поля и методы родительского класса, и, как правило, добавляет какой-то новый функционал или/и поля. Наследование описывается словом «является». Легковой автомобиль является автомобилем. Вполне естественно, если он будет его наследником.

```class Vehicle
{
    bool hasWheels;
}

class Car : Vehicle
{
    string model = "Porshe";
    int numberOfWheels = 4
}```

Ассоциация – это когда один класс включает в себя другой класс в качестве одного из полей. Ассоциация описывается словом «имеет». Автомобиль имеет двигатель. Вполне естественно, что он не будет являться наследником двигателя (хотя такая архитектура тоже возможна в некоторых ситуациях).

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

Композиция – это когда двигатель не существует отдельно от автомобиля. Он создается при создании автомобиля и полностью управляется автомобилем. В типичном примере, экземпляр двигателя будет создаваться в конструкторе автомобиля.

```
class Engine
{
    int power;
    public Engine(int p)
    {
        power = p;
    }
}

class Car
{
    string model = "Porshe";
    Engine engine;
    public Car()
    {
        this.engine = new Engine(360);
    }
}
```

Агрегация – это когда экземпляр двигателя создается где-то в другом месте кода, и передается в конструктор автомобиля в качестве параметра.

```
class Engine
{
    int power;
    public Engine(int p)
    {
       power = p;
    }
}        

class Car
{
    string model = "Porshe";
    Engine engine;
    public Car(Engine someEngine)
    {
         this.engine = someEngine;
    }
}

Engine goodEngine = new Engine(360);
Car porshe = new Car(goodEngine);

```

Хотя ведутся дискуссии о преимуществах того или иного способа организации взаимодействия между классами, какого-либо абстрактного правила не существует. Разработчик выбирает тот или иной путь основываясь на элементарной логике (“является” или “имеет”), но также принимает во внимание возможности и ограничения, которые дают и накладывают эти способы. Для того, чтобы увидеть эти возможности и ограничения, я попытался написать пример. Достаточно простой, чтобы код оставался компактным, но и достаточно развитый, чтобы в рамках одной программы можно было применить все три способа. И, главное, я попытался сделать этот пример как можно менее абстрактным – все объекты и экземпляры понятны и осязаемы.

Напишем простенькую игру – танковый бой. Играют два танка. Они поочередно стреляют и проигрывает тот, здоровье которого упало до нуля. В игре будут различные типы снарядов и брони. Для того, чтобы нанести урон необходимо во-первых, попасть по танку противника, во-вторых, пробить его броню. Если броня не пробита, урон не наносится. Логика игры построена на принципе «камень-ножницы-бумага»: то есть броня одного типа хорошо противостоит снарядам определенного типа, но плохо держит другие снаряды. Кроме того, снаряды, которые хорошо пробивают броню, наносят малый «заброневой» урон, и, напротив, наиболее «летальные» снаряды имеют меньше шансов пробить броню.

Создадим простенький класс для пушки. Он будет иметь два приватных поля: калибр и длину ствола. От калибра зависит урон, и, частично, способность к пробитию брони. От длины ствола – точность стрельбы.

```
public class Gun
{
    private int caliber;
    private int barrelLength;
} 
```

Сделаем также конструктор для пушки:

```
public Gun(int cal, int length)
{
    this.caliber = cal;
    this.barrelLength = length;
}
```

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

```
public int GetCaliber()
{
    return this.caliber;
}
```

Помните, что для поражения цели должно произойти две вещи: попадание в цель и пробитие брони? Так вот, пушка будет отвечать за первую из них: попадание. Поэтому делаем булевый метод IsOnTarget, который принимает случайную величину (dice) и возвращает результат: попали или нет:

```
public bool IsOnTarget(int dice)
{
    return (barrelLength + dice) > 100; 
}
```

Целиком класс пушки выглядит следующим образом:

```
public class Gun
{
   private int caliber;
   private int barrelLength;

   public Gun(int cal, int length)
   {
       this.caliber = cal;
       this.barrelLength = length;
   }

   public int GetCaliber()
   {
       return this.caliber;
   }

   public bool IsOnTarget(int dice)
   {
       return (barrelLength + dice) > 100; 
   }
}
```

Теперь сделаем снаряды – это наиболее очевидный случай для применения наследования, но и агрегацию в нем тоже применим. Любой снаряд имеет свои особенности. Просто неких гипотетических снарядов не бывает. Поэтому класс делаем абстрактным. Делаем ему строковое поле «тип».

Снаряды делают для пушек. Для определенных пушек. Снаряд одного калибра не выстрелит из пушки другого калибра. Поэтому добавляем снаряду поле-ссылку на экземпляр пушки. Делаем конструктор.

```
public abstract class Ammo
{
   Gun gun;
   public string type;
   public Ammo(Gun someGun, string type)
   {
       gun = someGun;
       this.type = type;
   }
}
```

Здесь мы применили агрегацию. Где-то будет создана пушка. Потом к этой пушке будут создаваться снаряды, которые имеют указатель на пушку.

Конкретные типы снарядов будут наследниками абстрактного снаряда. Наследники могут просто наследовать методы родителя, но могут и быть переопределены, то есть работать не так, как родительский метод. Но мы точно знаем, что любой снаряд должен иметь ряд методов. Любой снаряд должен наносить урон. Метод GetDamage просто возвращает калибр, умноженный на три. В общем случае, урон снаряда зависит от калибра. Но этот метод будет переопределяться в дочерних классах (помним, что снаряды, которые хорошо пробивают броню, как правило наносят меньший «заброневой» урон. Чтобы иметь возможность переопределить метод в дочернем классе, используем слово virtual.

```
public virtual int GetDamage()
{
      //TO OVERRIDE: add logic of variable damage depending on Ammo type
      return gun.GetCaliber()*3;
}
```

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

```
public int GetPenetration()
{
      return gun.GetCaliber();
}
```

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

```
public override string ToString()
{
      return $"Снаряд " + type + " к пушке калибра " + gun.GetCaliber();
}
```

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

Фугасный (дефолтный урон):

```
public class HECartridge : Ammo
{        
    public HECartridge(Gun someGun) : base(someGun, "фугасный") { }

    public override int GetDamage()
   {
       return (int)(base.GetDamage());            
   }
}
```

Кумулятивный (дефолтный урон х 0.6):

```
public class HEATCartridge : Ammo
{        
   public HEATCartridge(Gun someGun) : base(someGun, "кумулятивный") { }
        
   public override int GetDamage()
   {
            return (int)(base.GetDamage() * 0.6);
   }
}
```

Подкалиберный (дефолтный урон х 0.3):

```
public class APCartridge : Ammo
{      
   public APCartridge(Gun someGun) : base(someGun, "подкалиберный") {  }

   public override int GetDamage()
    {
        return (int)(base.GetDamage() * 0.3);
    }
}
```

Обратите внимание, что в переопределенном методе GetDamage вызывается и метод базового класса. То есть, переопределив метод, мы также сохраняем возможность обратиться к дефолтному методу, использовав ключевое слово base).

Итак, для снарядов мы применили и агрегацию (пушка в базовом классе), и наследование.
Создадим теперь броню для танка. Здесь применим только наследование. Любая броня имеет толщину. Поэтому абстрактный класс брони будет иметь поле thickness, и строковое поле type, которое будет определятся при создании дочерних классов.

```
public abstract class Armour
{
    public int thickness;
    public string type;

    public Armour(int thickness, string type)
    {
        this.thickness = thickness;
        this.type = type;
    }
}
```

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

```
public virtual bool IsPenetrated(Ammo projectile)
{
    return projectile.GetDamage() > thickness;
}
```

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

```
public class HArmour : Armour
{
    public HArmour(int thickness) : base(thickness, "гомогенная") { }
    public override bool IsPenetrated(Ammo projectile)
    {
        if (projectile is HECartridge)
        {
        	//Если фугасный, то толщина брони считается больше
              return projectile.GetPenetration() > this.thickness * 1.2;
        }
        else if (projectile is HEATCartridge)
        {
                //Если кумулятивный, то толщина брони нормальная
                return projectile.GetPenetration() > this.thickness * 1;
        }
        else
        {
                //Если подкалиберный, то считаем уменьшаем толщину
                return projectile.GetPenetration() > this.thickness * 0.7;
        }
    }
}
```

Здесь мы используем одно из чудес, которые дает полиморфизм. Метод принимает любой снаряд. В сигнатуре указан базовый класс, а не дочерние. Но внутри метода, мы можем увидеть, что за снаряд прилетел – какого типа. И в зависимости от этого, реализуем ту или иную логику. Если бы мы не применили наследование для снарядов, а сделали просто три уникальных класса типов снарядов, то проверку пробития брони пришлось бы организовывать иначе. Нам пришлось бы писать столько перегруженных методов, сколько типов снарядов у нас в игре, и вызывать один из них в зависимости от того, какой снаряд прилетел. Это тоже было бы довольно изящно, но не относится к теме данной статьи.

Теперь у нас все готово для создания танка. В танке не будет наследования, но будет композиция и агрегация. Разумеется, у танка будет название. У танка будет пушка (агрегация). Для нашей игры сделаем допущение, что танк может «переодевать» броню перед каждым ходом – выбрать тот или иной тип брони. Для этого, у танка будет список типов брони. У танка будет боеукладка – список снарядов, который будет наполнен снарядами, созданными в конструкторе танка (композиция!). У танка будет здоровье (уменьшается при попадании в него), и, у танка будет текущая выбранная броня и текущий выбранный снаряд.

```
public class Panzer
{
    private string model;
    private Gun gun;
    private List<Armour> armours;
    private List<Ammo> ammos;        
    private int health;
                
    public Ammo LoadedAmmo { get; set; }
    public Armour SelectedArmour { get; set; }
}
```

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

```
private void AddArmours(int armourWidth)
{
     armours.Add(new SArmour(armourWidth)); 
     armours.Add(new HArmour(armourWidth));
     armours.Add(new CArmour(armourWidth));
}

private void LoadAmmos()
{
     for(int i = 0; i < 10; i++)
     {
          ammos.Add(new APCartridge(this.gun));
          ammos.Add(new HEATCartridge(this.gun));
          ammos.Add(new HECartridge(this.gun));
     }
}
```

Теперь конструктор танка выглядит вот таким образом:

```
public Panzer(string name, Gun someGun, int armourWidth, int h)
{
    model = name;
    gun = someGun;
    health = h;
    armours = new List<Armour>();
    ammos = new List<Ammo>();
    AddArmours(armourWidth);
    LoadAmmos();
    LoadedAmmo = null;
    SelectedArmour = armours[0]; //по умолчанию - гомогенная броня
}```

Обратите внимание, что здесь мы снова используем возможности полиморфизма. Наша боекладка вмещает снаряды любого типа, так как список имеет тип данных Ammo – родительский снаряд. Если бы мы не наследовались, а создавали уникальные типы снарядов, пришлось бы делать отдельный список под каждый тип снаряда.

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

Выбрать броню:

```
public void SelectArmour(string type)
{
     for (int i = 0; i < armours.Count; i++)
     {
         if (armours[i].type == type)
         {
             SelectedArmour = armours[i];
             break;
         }
     }
}
```

Зарядить пушку:

```
public void LoadGun(string type)
{
     for(int i = 0; i < ammos.Count; i++)
     {
         if(ammos[i].type == type)
         {
             LoadedAmmo = ammos[i];
             Console.WriteLine("заряжено!");
             return;
         }
     }
     Console.WriteLine($"сорян, командир, " + type + " закончились!");
}
```

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

Выстрелить:

```
public Ammo Shoot()
{
    if (LoadedAmmo != null)
    {                
       Ammo firedAmmo = (Ammo)LoadedAmmo.Clone();
       ammos.Remove(LoadedAmmo);
       LoadedAmmo = null;
       Random rnd = new Random();
       int dice = rnd.Next(0, 100);
       bool hit = this.gun.IsOnTarget(dice);
        if (this.gun.IsOnTarget(dice))
        {
             Console.WriteLine("Попадание!");
             return firedAmmo;
        }
        else
        {
             Console.WriteLine("Промах!");
             return null;
        }              
     }
     else Console.WriteLine("не заряжено");
     return null;
 }
```

Здесь – поподробнее. Во-первых, есть проверка заряжена ли пушка. Во-вторых, снаряд, который вылетел из ствола, уже не существует для данного танка, его уже нет ни в пушке, ни в боеукладке. Но физически он еще существует – летит по направлению к цели. И если попадет, будет участвовать в вычислении пробития брони и урона цели. Поэтому, мы сохраняем этот снаряд в новой переменной: Ammo firedAmmo. Поскольку на следующей же строке данный снаряд перестанет существовать для данного танка, придется использовать интерфейс IClonable для базового класса снаряда:

```
public abstract class Ammo : ICloneable
```

Этот интерфейс требует реализации метода Clone(). Вот она:

```
public object Clone()
{
     return this.MemberwiseClone();
}
```

Теперь все супер реалистично: при выстреле генерируется dice, пушка рассчитывает попадание своим методом IsOnTarget, и, если попадание есть, то метод Shoot вернет экземпляр снаряда, а если промах – то вернет null.

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

```
public void HandleHit(Ammo projectile)
{
     if (SelectedArmour.IsPenetrated(projectile))
     {
         this.health -= projectile.GetDamage();                
     }
     else Console.WriteLine("Броня не пробита.");
}
```

Снова полиморфизм во всей красе. К нам прилетает снаряд. Любой. Исходя из выбранной брони и типа снаряда, вычисляется пробита броня или нет. Если пробита, то вызывается метод конкретного типа снаряда GetDamage().

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

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

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

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

Ниже – приведена диаграмма наших классов.



В финальном коде игры все «магические числа», которые использовались в тексте, вынесены в отдельный статический класс Config. К публичным полям статического класса мы можем обратиться из любого фрагмента нашего кода и его экземпляр не нужно (и невозможно) создавать. Вот так он выглядит:

```
public static class Config
    {
        public static List<string> ammoTypes = new List<string> { "фугасный", "кумулятивный", "подкалиберный" };
        public static List<string> armourTypes = new List<string> { "гомогенная", "разнесенная", "комбинированная" };

        //трешхолд для пушки - величина, выше которой будем считать, что снаряд попал в цель
        public static int _gunTrashold = 100;

        //дефолтный коэффициент для заброневого действия базового снаряда
        public static int _defaultDamage = 3;

        //коэффициенты урона для снарядов разных типов
        public static double _HEDamage = 1.0;
        public static double _HEATDamage = 0.6;
        public static double _APDamage = 0.3;

        //коэффициенты стойкости брони
        //для гомогенной:
        //Если в гомогенную броню прилетает фугасный, то ее толщина считается большей -  коэффициент 1.2
        public static double _HArmour_VS_HE = 1.2;
        //Если в гомогенную броню прилетает кумулятивный, то ее толщина считается нормальной -  коэффициент 1.0
        public static double _HArmour_VS_HEAT = 1.0;
        //Если в гомогенную броню прилетает подкалиберный, то ее толщина считается меньшей -  коэффициент 0.7
        public static double _HArmour_VS_AP = 0.7;

        //для комбинированной брони
        //Если в комбинированную броню прилетает фугасный, то ее толщина считается нормальной -  коэффициент 1
        public static double _СArmour_VS_HE = 1.0;
        //Если в комбинированную броню прилетает фугасный, то ее толщина считается меньше - коэффициент 0.8
        public static double _СArmour_VS_HEAT = 0.8;
        //Если в комбинированную броню прилетает фугасный, то ее толщина считается больше - коэффициент 1.2
        public static double _СArmour_VS_AP = 1.2;

        //Для разнесенной брони
        //Если в разнесенную броню прилетает фугасный, то ее толщина считается меньше - коэффициент 0.8
        public static double _SArmour_VS_HE = 0.8;

        //Если в  разнесенную броню прилетает кумулятивный, то ее толщина считается больше - коэффициент 1.2
        public static double _SArmour_VS_HEAT = 1.2;

        //Если в  разнесенную броню прилетает подкалибереый, то ее толщина считается нормальной - коэффициент 1
        public static double _SArmour_VS_AP = 1.0;
    }
```

И благодаря этому классу мы можем производить дальнейшую настройку, меняя параметры лишь здесь, без дальнейшего углубления в классы и методы. Если, например, мы пришли к выводу, что подкалиберный снаряд получился слишком сильным, то мы меняем одну циферку в Config.
Весь код игры можно увидеть вот здесь.
Теги:
Хабы:
Всего голосов 19: ↑13 и ↓6+7
Комментарии97

Публикации

Истории

Работа

Ближайшие события