В объектно-ориентированных языках программирования существует три способа организации взаимодействия между классами. Наследование — это когда класс-наследник имеет все поля и методы родительского класса, и, как правило, добавляет какой-то новый функционал или/и поля. Наследование описывается словом «является». Легковой автомобиль является автомобилем. Вполне естественно, если он будет его наследником.
```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.
Весь код игры можно увидеть вот здесь.
Комментарии (82)
lair
22.04.2018 20:10В объектно-ориентированных языках программирования существует три способа организации взаимодействия между классами. [...] Наследование описывается словом «является».
А почему никак не учтены интерфейсы? Потому что между интерфейсом и реализующим его классом тоже отношение "is a" (то, что вы называете "является"), но называть это наследованием не стоило бы.
alexs0ff
22.04.2018 21:33наследованием не стоило бы
Интерфейсы совершенно к ООП не относятся (ну то есть ООП возможно и без интерфейсов), хотя присутствуют в ООП языках.lair
22.04.2018 21:39В этом смысле и классы к ООП не относятся (ООП возможно и без классов). Почему классы рассматриваем, а интерфейсы — нет?
alexs0ff
22.04.2018 21:45классы к ООП
Пардон, как вы без типа будете что-то наследовать? Да я в курсе, что классы в тот же Cи внедрялись хитрыми макросами. Но был ли тогда ООП — большой вопрос, скорее более продвинутая группировка логически связанных методов.
Или я ваш посыл не понял.lair
22.04.2018 21:47Пардон, как вы без типа будете что-то наследовать?
Ну так и наследование для ООП не обязательно.
alexs0ff
22.04.2018 22:17Ну так и наследование для ООП не обязательно
Ну так и для автомобиля шины не обязательны. Он поедет и без них, но только долго ли выдержит и далеко ли уедет.
Никто в здравом уме не будет реализовывать современный ООП язык без наследования (ну или не включить в ближайшей версии).lair
22.04.2018 22:29Никто в здравом уме не будет реализовывать современный ООП язык без наследования
(Аргумент истинного шотландца?)
Вы про Go не слышали?
Или, вот, скажем, JS тоже вполне себе ОО-язык, а классов там не было (я, если честно, не помню, завезли ли).
Зачем конкретно, по-вашему, нужно наследование ОО-языку?
alexs0ff
22.04.2018 22:36Вы про Go не слышали?
Тут другой вопрос, является ли Go — ООПшным языком. Но на эту тему я холиварить не хочу. Т.к. у него свои границы применения, ну как у функциональных языках — вполне могут себе позволить обходится без многих ООПешностей.
JS тоже вполне себе ОО-язы
Ну так давно уже есть наследование, погуглите JavaScript extends. Ну вот первая ссылка developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Classes/extendslair
22.04.2018 23:47Тут другой вопрос, является ли Go — ООПшным языком.
Я же говорю: аргумент истинного шотландца.
Ну так давно уже есть наследование, погуглите JavaScript extends
Наследование там было намного раньше. Классов не было.
Но: так зачем же ОО-языку наследование?
alexs0ff
23.04.2018 08:07Классов не было.
Вот вам пример класса, которого «не было» на чистом JS (в функциональном стиле):
function Animal(name) { this.speed = 0; this.name = name; this.run = function(speed) { this.speed += speed; alert( this.name + ' бежит, скорость ' + this.speed ); }; this.stop = function() { this.speed = 0; alert( this.name + ' стоит' ); }; }; var animal = new Animal('Зверь');
lair
23.04.2018 11:10Так это и не класс, это фабричная функция.
alexs0ff
23.04.2018 11:52да нет, это определение и создание «прототипоориентированного объекта» prototype-based object. Которое в последствии получило синтаксический сахар в виде ключевого слова class.
lair
23.04.2018 11:53… я и говорю: это не класс, это создание объекта.
alexs0ff
23.04.2018 12:04что вы понимаете под словом «класс»? я лично — определенную конструкцию для описания какого-либо типа. Из которого можно потом создать объект.
я и говорю: это не класс
Ну т.е. класс из нового JS вы за класс и не считаете.oxidmod
23.04.2018 12:05Потому что это и не класс никакой. Это просто синтаксический сахар над прототипным наследованием
alexs0ff
23.04.2018 12:10Это понятно, что JS это прототипы. А класс — это просто способ описания.
Ну так что вы понимаете под словом «класс».
.Это просто синтаксический сахар над прототипным наследованием
И почитайте, что я писал две ветки назад
Которое в последствии получило синтаксический сахар в виде ключевого слова class.
oxidmod
23.04.2018 12:18Класс — это описание пользовательского типа данных. Экземпляр класса — всегда остается экземпляром этого класса (в js можно сменить прототип после создания объекта). Изменения в экземпляре (не статических свойств класса) влияют только на этот конкретный экземпляр (в js изменение в прототипе повлияет на все объекты с этим прототипом)
alexs0ff
23.04.2018 12:31Вот, дошли. Я понимаю класс как
«Класс — это описание пользовательского типа данных»
Все.
Далее пошли подуровни:
1) Классы в ОО типа C++/Java
2) Классы у прототипно ориентированных языков (например описанные для JS ES2015 Classes)
3) Мультиметоды у других языков.
4) и т.д. и т.п.
Видно, из-за этого и вся путаница, что я не правильно писал «класс» вместо «описание объекта».
lair
23.04.2018 12:29что вы понимаете под словом «класс»? я лично — определенную конструкцию для описания какого-либо типа.
Вот только там, что вы показали, никакого типа нет. Есть объект.
alexs0ff
23.04.2018 12:32Да, я неправильно писал «класс», а нужно было «описание объекта».
Признаюсь — неправильная терминология у меня.lair
23.04.2018 12:33Ура. Значит, в JS (ну, старом) классов нет. JS — ОО-язык?
alexs0ff
23.04.2018 12:41Я до этого момента, думал, что спор у нас из-за того что в JS нет инструментов описания/создания объектов. Неправильно интерпретировал слово «класс».
Да, классов пришедших из SmallTalk нет.lair
23.04.2018 13:03Значит, классы не обязательны для ООП. Что возвращает нас к вопросу: почему классы в статье рассматриваются, а интерфейсы — хотя во многих случаях они лучше подходят для решения задач, описанных в статье — нет?
alexs0ff
23.04.2018 13:07классы не обязательны для ООП
Еще раз. Я интерпретировал, что «описание/создание объектов» не обязательны для ООП. Поэтому у нас и возникла дискуссия.
почему классы в статье рассматриваются, а интерфейсы — хотя во многих случаях они лучше подходят для решения задач
Вам не кажется, что лучше этот вопрос задать автору статьи?lair
23.04.2018 13:11Вам не кажется, что лучше этот вопрос задать автору статьи?
Ну так с моего комментария с этим вопросом автору статьи (на который вы решили ответить) эта дискуссия и началась.
alexs0ff
23.04.2018 18:15Значит, в JS (ну, старом) классов нет. JS — ОО-язык
Отсутствие наследования и прочего разрешено в ООП, но тогда его полноценным не назовешь.
Со мной даже википедия согласна, что бывают недо «оопешные языки»
en.wikipedia.org/wiki/List_of_object-oriented_programming_languages
For example, C++ is a multi-paradigm language including object-oriented paradigm;[2] however, it is less object-oriented than some other languages such as Python[3] and Ruby.[4] Therefore, someone considers C++ as an OOP language, while others do not or prefer to name it as «semi-object-oriented programming language».
Так что деление «полу/недо оопешный язык» имеет место быть.lair
23.04.2018 19:10Отсутствие наследования и прочего разрешено в ООП, но тогда его полноценным не назовешь.
И вот теперь мы приходим к вопросу определения "полноценного ООП".
alexs0ff
23.04.2018 08:28так зачем же ОО-языку наследование?
Еще раз, если вам не нужно — не пользуйтесь. Я вас уверяю, даже если использовать процедурный стиль — написать можно все что угодно, чисто теоретически даже с goto, можно все засунуть в один метод.lair
23.04.2018 11:10Что мне нужно, я как-нибудь разберусь. Но это же вы утверждаете, что ОО-языку необходимо наследование, вот я и спрашиваю: зачем?
alexs0ff
23.04.2018 11:56-1Затем, чтобы его использовать в своих разработках. Это инструмент, я определяю базовую систему и собираю с помощью нее другие объекты (ну или несколько базовых систем, если у нас разрешено множественное наследование). Я прекрасно знаю способы обходится без нее. Так же я знаю способы обходится и без инкапсуляции и и прочего.
lair
23.04.2018 12:28Говорят, что для "сборки систем" лучше подходит композиция. Врут, наверное?
Хорошо, поставим вопрос иначе: если в языке нет наследования — он не ОО? Если да, то почему?
alexs0ff
23.04.2018 08:41Тут другой вопрос, является ли Go — ООПшным языком.
Я же говорю: аргумент истинного шотландца.
Ну так сами разработчики «сомневаются»
golang.org/doc/faq#Is_Go_an_object-oriented_language
Is Go an object-oriented language?
Yes and no. Although Go has types and methods and allows an object-oriented style of programming, there is no type hierarchy.
Но еще раз — я на эту тему холиварить не хочу.
lair
22.04.2018 22:36… я, кстати, могу с тем же успехом сказать, что никто в здравом уме не будет реализовывать современный ОО-язык без интерфейсов. Неправда, конечно, но дальше же можно поспорить за "современный" и "здравый ум".
alexs0ff
22.04.2018 22:39Вот тут не соглашусь — тот же JS, интерфейсы есть в typescript — но ИМХО — совершенно не нужная часть (В отличие от C# например)
lair
23.04.2018 00:08… а в JS уже завезли статическую типизацию, чтобы ему интерфейсы были нужны?
alexs0ff
23.04.2018 08:03Какая статическая типизация в JS? Нет там ее и не планируется, я писал о другом языке — typescript.
lair
23.04.2018 11:09Тогда не приводите JS в пример. Возьмем, значит, TS. Там множественное наследование есть? Вроде как не было.
alexs0ff
23.04.2018 12:13Причем тут множественное наследование? Мы говорим об интерфейсах.
lair
23.04.2018 12:30При том, что есть прямая связь между наличием или отсутствием интерфейсов как отдельной сущности и наличием или отсутствием множественного наследования. Понимаете, какая?
alexs0ff
23.04.2018 12:51Скажем так, в языках где есть множественного наследование, интерфейсы необязательны, хотя могут присутствовать.
В typescript, из-за его возможности динамической типизации (и отсутствия по понятным причинам проверки типов в рантайме), интерфейсы не обязательны. Можно прям в рантайме подставлять нужный тип (с некоторыми манипуляциями) и работать.lair
23.04.2018 13:04Вот вам и ответ: там нет интерфейсов, потому что там есть динамическая типизация. А теперь попробуйте добиться того же эффекта в языке со статической типизацией и без множественного наследования (привет, C#).
Lofer
23.04.2018 18:16Скажем так, в языках где есть множественного наследование, интерфейсы необязательны, хотя могут присутствовать.
В данном случае «интерфейс» можно представить как класс без единой строчки кода (одни декларации функций) и protected конструктор. Это фактически народ писал руками на С++ 90х годов :)
В данном случае интерфейс будет как «синтаксический сахар» для базового класса, который декларирует некоторый функционал.
mayorovp
23.04.2018 10:14Вот как раз в typescript это самая интересная часть. Без них не получится правильно типизировать библиотеки которые поставляются по принципу «ядро + плагины». Например, jquery или rx.
alexs0ff
23.04.2018 12:13Без них не получится правильно типизировать библиотеки
Если бы библиотеки были бы переписаны на typescript, то интерфейсы не нужны были, а так это просто связующее звено между легаси.mayorovp
23.04.2018 17:20Ну-ну, покажите как вы будете это делать. Вот представьте, что вы пишите аналог jquery на typescript и вам нужно добавить метод attr.
Ну и как это делать без интерфейсов, но с сохранением выбранной архитектуры (ядро + плагины)?
alexs0ff
23.04.2018 17:50но с сохранением выбранной архитектуры
Вы мне предлагаете с сохранением архитектуры построенной на интерфейсах переделать на такую же без интерфейсов? Это абсурдное требование. Очевидно, что система без интерфейсов будет выглядеть совершенно по другому, но полезную работу будет выполнять одну и туже.
anjensan
24.04.2018 15:50А не приведете ли Вы, как архитектор, четкое и понятное определение ООП (которого лично Вы придерживаетесь). Дабы не было разночтений и бестоковых споров.
lair
24.04.2018 15:57+1Я не придерживаюсь никакого четкого определения ООП, потому что я его пока не встретил. Мне ближе всего позиция Кея: "OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things".
anjensan
24.04.2018 16:11Спасибо. Жаль, ибо четкой и предметной дискуссии не получится (ибо есть только «плавающее определение» что же такое ООП). Ок :)
lair
22.04.2018 20:13Здесь мы используем одно из чудес, которые дает полиморфизм. Метод принимает любой снаряд. В сигнатуре указан базовый класс, а не дочерние. Но внутри метода, мы можем увидеть, что за снаряд прилетел – какого типа. И в зависимости от этого, реализуем ту или иную логику.
Типичный такой ад с нарушением инкапсуляции. А вот представьте, что у вас появился четвертый и пятый вид снарядов — что случится во всех классах брони?
Так что нет, полиморфизм — это если бы вы никак не взаимодействовали с типом прилетевшего снаряда, а только передавали ему необходимые параметры.
alexs0ff
22.04.2018 21:36передавали ему необходимые параметры
Передача параметров это не есть разве взаимодействие? Или вы тупо про инициализацию, тогда причем тут полиморфизм?lair
22.04.2018 21:41+1Передача параметров это не есть разве взаимодействие?
Передавали параметры снаряду, не зная его тип. Полиморфизм (тот, который subtyping) — он именно про это.
alexs0ff
22.04.2018 22:07Полиморфизм из ООП это как раз когда мы взаимодействуем с каким -либо типом, не зная его конкретной реализации. Т.е. когда на вход мы принимаем супертип и делаем с ним некоторые манипуляции, а теперь можем передать и его подтипы.
lair
22.04.2018 22:30Полиморфизм из ООП это как раз когда мы взаимодействуем с каким -либо типом, не зная его конкретной реализации.
Это называется "инкапсуляция".
Т.е. когда на вход мы принимаем супертип и делаем с ним некоторые манипуляции, а теперь можем передать и его подтипы.
А это называется subtyping polymorphism и не предполагает, что вы потом делаете
if
по типу.
Я, собственно, в самом начале спросил: вы понимаете, чем плох такой подход?
tzlom
22.04.2018 21:56три способа организации взаимодействия между классами
А вот шаблон проектирования Фабрика например, это какой способ организации?
Конечно их больше, а ваш код следует улучшать дальше, к примеру классы брони — обычная копипаста, добавление новой брони усложнено, а при изменении интерфейса класса нужно делать много работы. Эта проблема может быть решена как мета-программированием, так и в рамках ООП.
bgnx
22.04.2018 23:07-1Для меня ключевое понимание наследования и отличие его от композиции пришло после понимания механизма их работы. Приведу пример. Допустим у нас есть класс DBConnection объект которого представляет собой соединение с базой данных и базовые операции работы с базой. Применяя композицию обычно создают отдельный класс Repository который представляет собой crud-операции с базой, который в конструкторе создает объект соединения и сохраняет его каком-то поле и использует потом его для взаимодействия c базой данных в crud-методах. А вот применяя наследование вместо композиции класс Repository отнаследуется от DBConnection и добавит нужный код работы с crud. И здесь принципиальное отличие — в случае композиции при создании объекта Repository будет создано два объекта в рантайме (сам Repository и объект DBConnection) а применяя наследование — только один объект. А в случае если у нас будет цепочка из 10 различных сущностей которые что-то добавляют и переопределяют то с композицией это будет уже 10 объектов а с наследованием только один вне зависимости от длины этой цепочки (да хоть тысячу сущностей). В этом и суть наследования — оно позволяет вынести в compile-time декораторную логику экономя cpu-циклы и память
oxidmod
22.04.2018 23:18Здесь мы используем одно из чудес, которые дает полиморфизм. Метод принимает любой снаряд. В сигнатуре указан базовый класс, а не дочерние. Но внутри метода, мы можем увидеть, что за снаряд прилетел – какого типа.
Вот проверки на тип ниразу не полиморфизм, имхо. С таким подходом можно все методы на Object завязать, вот уж будет полиморфизм.
Lofer
23.04.2018 00:03A «owns» B = Composition: B has no meaning or purpose in the system without A
A «uses» B = Aggregation: B exists independently (conceptually) from A
smer44
23.04.2018 01:59насколько это всё не нужные ограничения, показывает следующий пример стирания типов без вредных эффектов.
Пусть код должен поддерживать разные видытанковбронетехники, колёсные и гусеничные, с разным количеством пушек, моторов и т.п.
пусть просто класс Tank имеет Map<TypeID, List>, не Object, чтобы туда дураки не кидали любые обьекты. TankPart имеет integer или enum поле TypeID уникальную для каждого типа или может быть заменено на Class смотря что в каком языке есть. При добавлении новой детали проводится довольно простая и очевидная операция:
map.get(part.typeId).add(part).
Интересно, особо внимательные заметили что описанное выше — питоний стиль??
с танком можно тогда делать более умные вещи такие как приказать стрелять из всех, либо из определённого числа орудий, либо разделить орудия в этой Map по более мелким классам, либо одни и те же детали добавить в разные классы чтоб вызывать для разных дел, причём это всё можно динамически менять с малым оверхедом на содержание карты.lair
23.04.2018 11:13+1… и как бишь в этой конструкции определить, что именно умеет конкретный TankPart — стрелять или ездить?
smer44
23.04.2018 18:49-2по typeID. Если не хочешь запоминать соответствие число -> что часть умеет, есть старый но проверенный способ это битовые флаги
lair
23.04.2018 19:12+1Т.е. потребитель должен знать список всех возможных TypeID, а поставщик — не забывать его обновлять, а если не дай бог поведение поменялось, то отследить это можно только тестами?
Lofer
24.04.2018 16:15В общем случае — да.
Не найдет ожидаемого в 'Run time' — сгенерится ошибка типа «NULL Reference exception» / «Application Exception» и т.д. в зависмости от архитектурыlair
24.04.2018 16:29Ну и зачем нам такое счастье без особых на него причин?
Lofer
24.04.2018 16:50Банально — экономия ресурсов, поскольку в RunTime ничего не проверяется.
Все проверяется на этапе компиляции статическими анализаторами (в лучшем случае).
Например, COM с такими спецэффектами работал, если руками править IDL не понимая всех последствий :)
Если еще древнее и нагляднее — динамическая загрузка DLL и ручной поиск указателей на функции :) В Pascal overlay механизм.
Это сейчас ресурсов дофига — и памяти и процессора, а раньше это был дефицит. Выравнивали побайтово, что бы поменьше места занимало.lair
24.04.2018 16:59Банально — экономия ресурсов, поскольку в RunTime ничего не проверяется.
Вы думаете, при вызове несуществующего метода ничего не проверяется? Не говоря уже о том, что в C# — о котором идет речь в статье — это немножко невозможно без дополнительных прыжков?
Lofer
24.04.2018 18:11-1Вы думаете, при вызове несуществующего метода ничего не проверяется?
Я не думаю, я знаю. При не корректном указателе — будет попытка исполнения не корректной операции процессора или обращения по некорректному указателю.
Дальше ОС отловит ошибку и прибьет процесс. Насмотрелся :)
Например: исполнить код в области данных. Сейчас такие операции контролируются на уровне ОС).
Не говоря уже о том, что в C# — о котором идет речь в статье — это немножко невозможно без дополнительных прыжков?
Вопрос был другой и С# не касался.
А насчет С# есть стандарт:
New types—value types and reference types—are introduced into the CTS via type declarations expressed in metadata. In addition, metadata is a structured way to represent all information that the CLI uses to locate and load classes, lay out instances in memory, resolve method invocations, translate CIL to native code, enforce security, and set up runtime context boundaries.
lair
24.04.2018 18:13+1Например: исполнить код в области данных. Сейчас такие операции контролируются на уровне ОС
Это ваше "контролируется на уровне ОС" — это не проверка, по-вашему?
Вопрос был другой и С# не касался.
Да нет, вопрос был в контексте поста, а пост — про C#.Lofer
25.04.2018 00:45Это ваше «контролируется на уровне ОС» — это не проверка, по-вашему?
Это все таки не зависит от языка или среды исполнения. Одинаково будет «ловить ошибки» что на С++ что на Java или .Net
К примеру:
Overview of the Protected Mode Operation of the Intel Architecture
…
If we look back at the segment descriptor you will see information in the descriptor that relates to more than just its base address in memory (Figure 2 & Table 1). The additional information provided is primarily for the implementation of a protected system:
• How programs can access different types of segments,
• ensuring accesses within the limits of the segment (limit checking),
• maintaining privilege levels or who has access to a segment,
• and controlling access to privileged instructions.
Возвращаясь к С#
Т.е. потребитель должен знать список всех возможных TypeID, а поставщик — не забывать его обновлять, а если не дай бог поведение поменялось, то отследить это можно только тестами?
Следует ответ «Да должен знать всегда», поскольку стандарт говорит следующее:
Signatures are the part of a contract that can be checked and automatically enforced. Signatures are formed by adding constraints to types and other signatures. A constraint is a limitation on the use of or allowed operations on a value or location. Example constraints would be whether a location can be overwritten with a different value or whether a value can ever be changed.
All locations have signatures, as do all values.
Type safety and verification
Since types specify contracts, it is important to know whether a given implementation lives up to these contracts. An implementation that lives up to the enforceable part of the contract (the named signatures) is said
to be type-safe. An important part of the contract deals with restrictions on the visibility and accessibility of named items as well as the mapping of names to implementations and locations in memory.
Type-safe implementations only store values described by a type signature in a location that is assignment-compatible (§8.7) with the location signature of the location (see §8.6.1).
Type-safe implementations never apply an operation to a value that is not defined by the exact type of the value. Type-safe implementations only access locations that are both visible and accessible to them. In a type-safe implementation, the exact type of a value cannot change.
Verification is a mechanical process of examining an implementation and asserting that it is type-safe.
Verification is said to succeed if the process proves that an implementation is type-safe. Verification is said to fail if that process does not prove the type safety of an implementation. Verification is necessarily conservative:
it can report failure for a type-safe implementation, but it never reports success for an implementation that is not type-safe.
For example, most verification processes report implementations that do pointer-based arithmetic as failing verification, even if the implementation is, in fact, type-safe.
lair
25.04.2018 00:59Это все таки не зависит от языка или среды исполнения.
ОС — это "среда исполнения". Так что зависит. И даже процессор — это "среда исполнения".
Но суть все равно не в этом, а в том, что в современных реалиях попытка вызвать несуществующий метод небесплатна: именно вследствие дополнительных проверок, защищающих систему от сбоев.
Следует ответ «Да должен знать всегда», поскольку стандарт говорит следующее:
А какое отношение этот стандарт имеет к самописным идентификаторам?
(более того, даже этот стандарт не говорит, что потребитель должен знать список всех типов, которые могут в него передать — только те ограничения, которые он накладывает на принимаемые типы)
Lofer
25.04.2018 01:43Но суть все равно не в этом, а в том, что в современных реалиях попытка вызвать несуществующий метод небесплатна: именно вследствие дополнительных проверок, защищающих систему от сбоев.
В общем проверка бесплатна, поскольку аппаратная и уже есть.
Срабатывает один раз, а не постоянно.
Скажем так — это неизбежное «константное» зло :) Скорее всего, обработка ошибок такого типа будет значительно дороже.
А какое отношение этот стандарт имеет к самописным идентификаторам?
Это в общем-то идентификаторы типов .Net.
И да, они самописны… компилятором :)
(более того, даже этот стандарт не говорит, что потребитель должен знать список всех типов, которые могут в него передать — только те ограничения, которые он накладывает на принимаемые типы)
И тут Вы правы, ибо этот же стандарт отдает реализацию на усмотрение разработчика :)
…
The choice of a particular verification process is thus a matter of engineering, based on the resources available to make the decision and the importance of detecting the type safety of different programming constructs.
Например:
When a class is loaded at runtime, the CLI loader imports the metadata into its own in-memory data structures, which can be browsed via the CLI Reflection services. The Reflection services should be considered as similar to a compiler; they automatically walk the inheritance hierarchy to obtain information about inherited methods and fields, they have rules about hiding by name or name-and-signature, rules about inheritance of methods and
properties, and so forth.lair
25.04.2018 01:47В общем проверка бесплатна, поскольку аппаратная и уже есть.
Эээ… нет же. Не бесплатна.
Срабатывает один раз, а не постоянно.
На каждом вызове же.
Это в общем-то идентификаторы типов .Net.
То, что предлагают в комментарии в начале треда? Нет, это не они.
А если заменить то, что там предлагается, на нормальные типы .net, то мы получим нормальную статически типизированную систему.
Lofer
25.04.2018 02:10В общем проверка бесплатна, поскольку аппаратная и уже есть.
Эээ… нет же. Не бесплатна.
Транзисторы есть и с этой точки зрения да «уплочено». Влияет ли это на латентность или пропускную способность памяти с учетом того, что все эти расчеты делаются на уровне стека и аккумулятора процессора?
Срабатывает один раз, а не постоянно
На каждом вызове же.
Если на первом вызове выяснится, что не корректный адрес функции и приложение попробует вызвать эту функцию, то приложение будет «ломиться до посинения»? Или ОС просто прибьет не корректное приложение и на этом все закончится?
Да и после формирования пространства процесса (выделение и распределения памяти, загрузки кода и данных) ОС не занимается перетасовкой адресов функций «потому что скучно», это ответсвенность самого процесса, в рамках предоставленых прав «сегмент кода»/«сегмент данных».
Если, конечно не задаться целью наваять такое «забавное» приложение.lair
25.04.2018 11:45Влияет ли это на латентность или пропускную способность памяти с учетом того, что все эти расчеты делаются на уровне стека и аккумулятора процессора?
По сравнению с отсутствием этих расчетов — конечно, влияет.
Если на первом вызове выяснится, что не корректный адрес функции и приложение попробует вызвать эту функцию, то приложение будет «ломиться до посинения»? Или ОС просто прибьет не корректное приложение и на этом все закончится?
А если адрес корректный, сколько раз будет выполняться проверка?
Давайте с другой стороны посмотрим. Вот исходное предложение:
Пусть код должен поддерживать разные виды танков бронетехники, колёсные и гусеничные, с разным количеством пушек, моторов и т.п.
пусть просто класс Tank имеет Map<TypeID, List>, не Object, чтобы туда дураки не кидали любые обьекты. TankPart имеет integer или enum поле TypeID уникальную для каждого типаОчевидно, что каждый
TankPart
имеет разные операции (пушка — стреляет, фара — светит, мотор — крутится, и так далее). Предположим, что нам надо сказать "включить все фары". Как это сделать в рамках исходного предложения, и чем это выгоднее стандартногоICollection<Light>
? Напомню, что контекст — C#, .net.Lofer
25.04.2018 13:35Влияет ли это на латентность или пропускную способность памяти с учетом того, что все эти расчеты делаются на уровне стека и аккумулятора процессора?
По сравнению с отсутствием этих расчетов — конечно, влияет.
Да в общем-то нет. Как было X операций в N единиц времени, так и будет. Эти битовые маски расчитываются и контролируются аппаратно процессором в процессе исполнения комманд.
Например согласно "“Protection” руководства “Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A & 3B):System Programming Guide”
Процессор использует эту информаци. для детектирования программных ошибок результатом которых является попытка использования сегмента или шлюза (gate) неверным или неожиданным способом.
…
Следующий список дает примеры типичных операций в которых выполняется проверка типов (список не исчерпывающий):
…
В процессе определенных внутренних операций,
При вызове или переходе через call gate (или при прерывании или обработчике исключения через trap или interrupt gate) процессор автоматически проверяет что сегментный дескриптор указанный в gate является кодовым сегментом.
Когда операнд инструкции содержит селектор сегмента
определенным инструкциям разрешен доступ к сегментам или шлюзам (gates) только определенного типа, например:
Дальнему вызову CALL или инструкции JMP разрешен доступ к сегментному дескриптору содержащему «conforming code segment», «nonconforming code segment», call gate, task gate или TSS.
и
Program-Error Exceptions
Процессор генерирует одно или более исключений при обнаружении программных ошибок в процессе выполнении приложения, кода операционной системы или executive. Архитектуры Intel64 и IA-32 определяют vector number для каждого processor-detectable exception.
Исключения подразделяются на faults, traps и aborts.
Программно-генерируемые исключения
Инструкции INTO, INT3 и BOUND позволяют программную генерацию исключений. Эти инструкции позволяют выполнение проверки условий в местах выполнения потока инструкций. Например INT 3 вызывает генерацию breakpoint exception.
Machine-Check Exceptions
Процессоры семейств P6 family и Pentium предоставляют внутренние и внешние machine-check механизмы для проверки операций внутреннего аппаратного чипа и транзакций шины. Эти механизмы — implementation dependent (непереносимы). Когда процессор обнаруживает machinecheck
ошибку, процессор сигнализирует об ошибке с помощью machine-check exception (vector 18) и возвращает код ошибки.
Что касается «Предположим, что нам надо сказать „включить все фары“. Как это сделать в рамках исходного предложения, и чем это выгоднее стандартного ICollection? Напомню, что контекст — C#, .net.»
Ответ прост — кто-то должен будет сделать эту работу по выяснению наличия возможности «включить».
Например:
if (unit is IOnOff)
{
((IOnOff)unit).OFF();
}
else
{
throw new Exception();
}
или
*p->Off();
Или это сделает программист руками, или ОС внутреними функциями или CPU аппаратными возможостями. Просто обработка ошибок более дорогое удовольствие, чем нормальны код.lair
25.04.2018 13:58*p->Off();
Я что-то не думаю, что в C# можно так написать. Фиг с ним, с указателем, но вам же надо знать, что такое
Off
, а вы этого не сделаете без операции приведения типа.
Так что ответа на мой вопрос я так и не вижу.
И это не говоря о том, что сравниваю я с вариантом
ICollection<ITankLight>.ForEach(l => l.Off())
, в котором как раз операций приведения нет.
lair
А откуда вы взяли это магическое определение?