×

Наслідування у програмуванні, чи як не вистрілити собі в ногу

Привіт! Мене звати Олесь, я Senior .Net Developer в компанії Stepico Games. Якщо ви пишете код і хоч раз проходили співбесіду, то повинні знати, що таке наслідування і принцип підстановки Лісков.

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

Типи взаємодії між об’єктами

Для початку, розберемось, які бувають варіанти взаємодії між об’єктами. Існує декілька типів взаємодії:

  • Наслідування;
  • Композиція;
  • Агрегація;
  • Асоціація.

Наслідування

Наслідування — це «IS — A» тип відношення двох об’єктів. Наслідування — це взаємодія типу базовий-спадковий, де ми створюємо новий клас, використовуючи наявний код. Це наче сказати «A is type of B». Наприклад: Яблуко — це Фрукт, Феррарі — це Машина. Тип зв’язку «IS — A» створює найбільш строгий варіант взаємодії між класами в С#.

public class BaseClass
    {
        public virtual void BaseMethod()
        {
            //Do smth
        }
    }

public class DerivedClass : BaseClass
    {
        public override void BaseMethod()
        {
            base.BaseMethod();

            //Do smth
        }
    }

Композиція

Композиція — це тип відношення двох об’єктів «PART-OF». В композиції два об’єкти є взаємозалежними. Наприклад: Двигун — частина Машини, Серце — частина Тіла. «PART-OF» менш зв’язаний тип взаємодії, хоча один об’єкт повністю контролює час життя, іншого.

public class CompositionClass
    {
        private readonly ComposedClass _composedClass;

        public CompositionClass()
        {
            _composedClass = new ComposedClass();
        }

        public void Method()
        {
            _composedClass.Method();
        }
    }

public class ComposedClass
    {
        public void Method()
        {
            // Do smth
        }
    }

Агрегація

Агрегація — це «HAS-A» тип відношення двох об’єктів. В агрегації один об’єкт використовує інший для якоїсь роботи, але не контролює час життя. Агрегація створює менш слабку взаємодію між об’єктами, ніж два попередні типи відношення.

public class AggregationClass
    {
        private readonly AggregatedClass _aggregatedClass;

        public AggregationClass(AggregatedClass aggregatedClass)
        {
            _aggregatedClass = aggregatedClass;
        }

        public void Method()
        {
            _aggregatedClass.Method();
        }
    }

public class AggregatedClass
    {
        public void Method()
        {
        }
    }

Асоціація

Асоціація — це «HAS-A» тип відношення двох об’єктів. Асоціація встановлює відношення між типами через їх об’єкти. Вона може один до одного, один до багатьох, багато до багатьох. Як приклад ми маємо два класи і ці два класи мають відношення «HAS-A» якщо обидва ці класи розділяють об’єкти один одного для якоїсь роботи, але ці два класса можуть існувати один без одного. Асоціація створює найменш слабку взаємодію між двома об’єктами.

public class Association
    {
        public void Method(AssociatedClass associatedClass)
        {
            associatedClass.Method();
        }
    }

public class AssociatedClass
    {
        public void Method()
        {
        }
    }

На що впливає сила взаємодії об’єктів?

Що більше два об’єкти зв’язані між собою, то більше зміни в одному із них будуть впливати на інший — і в такий спосіб впливати на стійкість системи в цілому. Так об’єкти, які використовують наслідування, створюють жорстку взаємодію, тому підтримання і розширення коду стає більш складною задачею. Ось чому важливо розуміти, як правильно використовувати наслідування, бо цей тип взаємодії створює найбільшу зв’язаність двох об’єктів в системі.

Принцип підстановки Лісков

Я думаю, всі, хто професійно займається кодуванням, знають принципи SOLID, або хоч раз чули про них на співбесіді. Але нас зараз цікавить літера «L» яка позначає «Liskov Substitution Principle». Трохи розберемось, що означає цей легендарний принцип:

Спочатку декілька слів по Барбару Лісков, яка його сформулювала.

Барбара Лісков — американська дослідниця в галузі інформатики, лауреатка премії Тюрінга 2008 року. Зараз є професоркою у Массачусетському технологічному інституті. Вперше вона представила принцип підстановки Лісков на конференції «Data abstraction» в 1987 році. Він мав такий вигляд:

«Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.»

Нехай Φ(x) є властивістю, правильною для об’єкта x типу T. Тоді Φ(y) буде правдиве для об’єкта у типу S, де S є підтипом T.

Трохи далі в часі цей принцип був перефразований Робертом Мартіном.

Функції, які використовують посилання на базовий клас, повинні мати можливість використовувати дочірній клас, не знаючи про це.

Виглядає це все дуже абстрактно, тому подивімося на прикладі.

Ще зі школи нас вчили, що квадрат є окремим випадком прямокутника. Пропоную перенести ці знання в код.

public class Rectangle
    {
        public virtual int Width { get; set; }
        public virtual int Height { get; set; }

        public int GetArea()
        {
            return Width * Height;
        }
    }

public class RectangleTests
    {
        [Theory]
        [AutoData]
        public void Rectangle_ShouldReturn_GetArea(int width, int height)
        {
            // Arrange
            var rectangle = new Rectangle() { Height = height, 
                                              Width = width };

            // Act
            // Assert
            rectangle.GetArea().Should().Be(width * height);
        }
    }

У нас є клас «Rectangle», а також є тест на нього, який перевіряє правильність обчислення площі прямокутника. Тепер зробимо клас для квадрата.

public class Square : Rectangle
    {
        public override int Width
        {
            get => base.Width;

            set
            {
                base.Width = value;
                base.Height = value;
            }
        }

        public override int Height
        {
            get => base.Height;

            set
            {
                base.Height = value;
                base.Width = value;
            }
        }
    }

Ми створили клас «Square», який є дочірнім від «Rectangle». Як ми знаємо з математики, у квадраті сторони рівні, що ми й робимо перевизначенням двох властивостей «Width» та «Height».

Як відомо з принципу підстановки Лісков, ми повинні мати можливість підставити дочірній клас замість базового без знання про це. Можемо це перевірити й підмінити клас «Rectangle» класом «Square» в нашому тесті.

public class RectangleTests
    {
        [Theory]
        [AutoData]
        public void Rectangle_ShouldReturn_GetArea(int width, int height)
        {
            // Arrange
            var rectangle = new Square { Height = height, Width = width };

            // Act
            // Assert
            rectangle.GetArea().Should().Be(width * height);
        }
    }

Виявляється, що при заданій підстановці наш тест не проходить, бо умова, яка працює для прямокутника, не працює для квадрата. Таким чином ми порушили принцип підстановки Лісков.
То як нам зрозуміти, як правильно конструювати дочірній клас? В цьому допоможуть принципи проєктування за контрактом.

Проєктування за контрактом

Термін був запропонований Бертраном Меєром під час розробки мови Eiffel і описаний в кількох статтях та книзі.

Стосовно принципу підстановки Лісков існує 3 умови, які повинні виконуватись при використанні наслідування:

  1. Прекондишен не повинен бути посилений в дочірньому класі.
  2. Посткондишен не повинен бути послаблений в дочірньому класі.
  3. Інваріант класу не повинен бути зміненим в дочірньому класі.

Знов виглядає доволі абстрактно, тож розглянемо це все на прикладі.

Повернемось до прикладів з фігурами, але додамо трохи логіки. Додамо логіку нашому класу Rectangle.

public class Rectangle
    {
        private short _drawingOrder;

        public short DrawingOrder
        {
            get => _drawingOrder;
            set
            {
                if (value > 2)
                    throw new Exception(
                        "Drawing order can`t be more than 2");
                _drawingOrder = value;
            }
        }
        public virtual int Width { get; set; }
        public virtual int Height { get; set; }

        public int GetArea()
        {
            return Width * Height;
        }

        public virtual void Draw()
        {
            if (GetArea() < 0)
            {
                throw new Exception(
                    "Rectangle can`t be be less than 0");
            }

            // Draw logic
        }

        public virtual double CalculateDrawingDiagonal(int screenDiagonal)
        {
            var calculateResult = Calculate();

            if (calculateResult < screenDiagonal)
            {
                calculateResult *= 0.9;
            }

            return calculateResult;
        }

        protected double Calculate()
        {
            return Math.Sqrt(Math.Pow(Width, 2) + Math.Pow(Height, 2));
        }
    }

В класі «Rectangle» ми додали метод «Draw» для виведення нашого прямокутника на екран, метод обчислення довжини діагоналі «CalculateDrawingDiagonal» і властивість «DrawingOrder» с обмеженням, за яким ця властивість не може бути більшою за 2. Також ми маємо тести на цей клас:

        [Theory]
        [InlineData(4, 4)]
        [InlineData(5, 5)]
        [InlineData(9, 9)]
        [InlineData(10, 10)]
        public void Draw_ShouldNotThrowException(int width, int height)
        {
            // Arrange
            var rectangle = new Rectangle()
            {
                Height = height, Width = width
            };

            // Act
            var action = () => rectangle.Draw();

            // Assert
            action.Should().NotThrow<Exception>();
        }

        [Theory]
        [InlineData(1, 2, 5, 2)]
        [InlineData(2, 3, 5, 4)]
        [InlineData(3, 4, 5, 5)]
        [InlineData(4, 5, 5, 5.4)]
        public void GetInterest_ShouldReturnProperValue(
            int width,
            int height,
            int screenDiagonal,
            double result)
        {
            // Arrange
            var rectangle = new Rectangle()
            {
                Height = height,
                Width = width
            };

            // Act
            // Assert
            rectangle
                .CalculateDrawingDiagonal(screenDiagonal)
                .Should().Be(result);
        }

        [Theory]
        [InlineAutoData(5)]
        public void Credit_ShouldSetCredit_Test(
            short drawingOrder,
            int width,
            int height)
        {
            // Arrange
            var action = () => new Rectangle()
            {
                Height = height,
                Width = width,
                DrawingOrder = drawingOrder
            };

            // Assert
            action.Should().Throw<Exception>()
                .WithMessage("Drawing order can`t be more than 2");
        }

У нас тепер є система, яка стабільно працює і виконує поставлені задачі. Але завтра приходить бізнес і каже, що потрібно створити новий тип прямокутника, який буде використовуватися для виведення невеликих прямокутників, але поверх усіх інших.

Перше, що спадає на думку: зробити просто наслідування від нашого прямокутника і перевизначити методи, які у нас змінилися.

public class LimitedRectangle : Rectangle
    {
        public override short DrawingOrder { get; set; }

        public override void Draw()
        {
            if (GetArea() > 100)
            {
                throw new Exception(
                    "LimitedRectangle can`t be draw in area more than 10");
            }

            base.Draw();
        }

        public override double CalculateDrawingDiagonal(int screenDiagonal)
        {
            return Calculate();
        }
    }

Розберемо, що ми змінили в LimitedRectangle.

В методі «Draw» додали умову, що наш прямокутник не повинен бути більшим за 100. Оскільки наш прямокутник не буде великим, то в методі «CalculateDrawingDiagonal» нам не потрібна умова порівняння «calculateResult» із «screenDiagonal», тому ми перевизначили метод «CalculateDrawingDiagonal». А оскільки у нас з’явилась умова, що «LimitedRectangle» повинен мати можливість бути намальованим поверх усіх інших, то ми перевизначили властивість «DrawingOrder».

Здається, що ми виконали задачу, але коли ми змінюємо в тестах «Rectangle» на «LimitedRectangle», то тести падають. То що ж ми порушили? Повернімося до принципів проєктування за контрактом.

Прекондишен не повинен бути посилений в дочірньому класі

В класі «Rectangle» у нас був метод:

public virtual void Draw()
        {
            if (GetArea() < 0)
            {
                throw new Exception(
                    "Rectangle can`t be be less than 0");
            }

            // Draw logic
        }

В цьому методі є прекондишен: «GetArea» не може бути меншою за 0. Але в «LimitedRectangle» ми перевизначаємо цей метод:

public override void Draw()
        {
            if (GetArea() > 100)
            {
                throw new Exception(
                    "LimitedRectangle can`t be draw in area more than 10");
            }

            base.Draw();
        }

І додаємо ще один прекондишен: «GetArea» не може перевищувати 100. Так ми порушили одну з умов принципа підстановки Лісков, що в дочірньому класі ми не повинні посилювати прекондишен.

Посткондишен не повинен бути ослаблений в дочірньому класі

В класі «Rectangle» у нас був метод:

public virtual double CalculateDrawingDiagonal(int screenDiagonal)
        {
            var calculateResult = Calculate();

            if (calculateResult > screenDiagonal)
            {
                calculateResult *= 0.9;
            }

            return calculateResult;
        }

В цьому методі у нас є посткондишен: якщо «calculateResult» більше за «screenDiagonal», то ми помножуємо «calculateResult» на 0.9. В «LimitedRectangle» ми перевизначаємо цей метод.

public override double CalculateDrawingDiagonal(int screenDiagonal)
        {
            return Calculate();
        }

І прибираємо цю умову. Так ми порушили іншу умову принципа підстановки Лісков про те, що дочірньому класі не повинен бути ослаблений посткондишен.

Інваріант класса не повинен бути зміненим в дочірньому класі

Інваріантом класса «Rectangle є умова, що властивість «DrawingOrder» не може бути більшою за 2.

public virtual short DrawingOrder
        {
            get => _drawingOrder;
            set
            {
                if (value > 2)
                    throw new Exception(
                        "Drawing order can`t be more than 2");
                _drawingOrder = value;
            }
        }

І коли ми перевизначаємо цю властивість в «LimitedRectangle»:

public override short DrawingOrder { get; set; }

То порушуємо останню умову принципа підстановки Лісков про те, що інваріант класса не повинен бути змінений в дочірньому класі.

Ці приклади показують, як можна оцінювати коректність наслідування, і як, користуючись принципами проєктування за контрактом, зрозуміти, чи порушує наслідування принцип підстановки Лісков.

Приклад з качечкою

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

Сама програма це просто консольний застосунок, який має такий вигляд:

using DuckExample;
using Microsoft.Extensions.DependencyInjection;

var serviceProvider = new ServiceCollection()
    .AddDucks()
    .BuildServiceProvider();

var duckSwimProvider = new DuckSwimProvider(serviceProvider.GetService<IEnumerable<IDuck>>()!);
duckSwimProvider.Swim();

У нас є клас «Duck»:

public class Duck : IDuck
    {
        public void Swim()
        {
            Console.WriteLine("quack quack");

            Console.WriteLine("- - - - - - - - -");

            Console.WriteLine("I'm swimming like a god");
        }
    }

У нас є метод розширення «AddDucks», в якому ми реєструємо клас «Duck» по інтерфейсу «IDuck»

        public static IServiceCollection AddDucks(this IServiceCollection  services)
        {
            services.AddSingleton<IDuck, Duck>();
            return services;
        }

Також у нас є клас «DuckSwimProvider», в якому ми отримуємо в конструкторі массив «IDuck», та в методі «Swim» викликаємо метод «Swim» всім об’єктам з массив «IDuck».

public class DuckSwimProvider
    {
        private readonly IEnumerable<IDuck> _ducks;

        public DuckSwimProvider(IEnumerable<IDuck> ducks)
        {
            _ducks = ducks;
        }

        public void Swim()
        {
            foreach (var duck in _ducks)
            {
                duck.Swim();
            }
        }
    }

Можна запустити наш застосунок і побачити, що качечка пливе.

Добре, а тепер додамо ще одну качечку. Але тепер вона буде трохи іншою, це буде механічна качка.

public class MechanicalDuck : IDuck
    {
        private bool _isStarted;

        public void Start()
        {
            _isStarted = true;
        }

        public void Swim()
        {
            if (!_isStarted) 
                return;

            Console.WriteLine("q-u-a-c-k q-u-a-c-k");

            Console.WriteLine("- - - - - - - - - - - - - -");

            Console.WriteLine("I`m alive and I'm swimming");
        }
    }

Оскільки наша качечка механічна, її треба запустити, щоб вона попливла. Для цього в класі «MechanicalDuck» є метод «Start», а в методі «Swim» є перевірка на те, що прапорець «_isStarted» змінений на «true».

Також додамо нову качечку в DI-контейнер.

public static IServiceCollection AddDucks(this IServiceCollection services)
        {
            services.AddSingleton<IDuck, Duck>();
            services.AddSingleton<IDuck, MechanicalDuck>();
            return services;
        }

Запускаємо нашу програму, але отримуємо аналогічний результат

Що логічно, адже нашу другу качку ніхто не запускає. Що ж, давайте це виправимо.

       public void Swim()
        {
            foreach (var duck in _ducks)
            {
                if (duck is MechanicalDuck mechanicalDuck)
                {
                    mechanicalDuck.Start();
                }

                duck.Swim();
            }
        }

Ми привели наш об’єкт типу інтерфейс «IDuck» до конкретного класу «MechanicalDuck» і викликали метод «Start». Так наша механічна качка зможе поплисти.

Тепер перевіримо все і запустимо наш консольний застосунок.

Хоча ми і отримали правильний результат, але нам довелось робити приведення абстракції до конкретного типу. І це є ще однією ознакою порушення принципа підстановки Лісков. Це означає, що абстракції, які ми використали, є помилковими. Зі зміною нашою системи далі можуть виникнути проблеми, які суттєво ускладнять подальшу розробку.

Рефакторинг цієї архітектури можна зробити різними способами: як мені здається, я маю більш-менш оптимальний варіант.

Пропоную ввести ще одну абстракцію «IDuckBuilder».

public interface IDuckBuilder
{
    IDuck Build();
}

Реалізації якої в нашому випадку будуть мати такий вигляд:

public class DuckBuilder : IDuckBuilder
    {
        private readonly Duck _duck;

        public DuckBuilder(Duck duck)
        {
            _duck = duck;
        }

        public IDuck Build()
        {
            return _duck;
        }
    }

public class MechanicalDuckBuilder : IDuckBuilder
    {
        private readonly MechanicalDuck _mechanicalDuck;

        public MechanicalDuckBuilder(MechanicalDuck mechanicalDuck)
        {
            _mechanicalDuck = mechanicalDuck;
        }

        public IDuck Build()
        {
            _mechanicalDuck.Start();
            return _mechanicalDuck;
        }
    }

Тепер ми сховали за рівнем абстракції роботу із запуску нашої качки. Тож всі маніпуляції із створенням, запуском чи чимось іншим залишаються на рівні «IDuckBuilder». Наприклад, нам захочеться створити іншопланетну качечку, якій для того, щоб поплисти, потрібно буде випити ядерного палива. Тоді нам потрібно буде лише створити новий білдер, який буде інкапсулювати цю роботу.

Подивимось, як треба змінити наш застосунок для використання «IDuckBuilder»:

Спочатку ми додаємо використання «IDuckBuilder» в «DuckSwimProvider»

public class DuckSwimProvider
    {
        private readonly IEnumerable<IDuckBuilder> _ducksBuilders;

        public DuckSwimProvider(IEnumerable<IDuckBuilder> ducksBuilders)
        {
            _ducksBuilders = ducksBuilders;
        }

        public void Swim()
        {
            foreach (var duckBuilder in _ducksBuilders)
            {
                var duck = duckBuilder.Build();
                duck.Swim();
            }
        }
    }

Далі зареєструємо наші реалізації «IDuckBuilder» в метод розширення «AddDucks»

public static IServiceCollection AddDucks(this IServiceCollection services)
        {
            services.AddSingleton<Duck>();
            services.AddSingleton<MechanicalDuck>();
            services.AddSingleton<IDuckBuilder, DuckBuilder>();
            services.AddSingleton<IDuckBuilder, MechanicalDuckBuilder>();
            return services;
        }

І наостанок змінимо саму консольну програму:

using DuckPossibleSolution;
using DuckPossibleSolution.Builders;
using Microsoft.Extensions.DependencyInjection;

var serviceProvider = new ServiceCollection()
    .AddDucks()
    .BuildServiceProvider();

var duckSwimProvider = new DuckSwimProvider(
    serviceProvider.GetService<IEnumerable<IDuckBuilder>>()!);
duckSwimProvider.Swim();

Запустимо і отримаємо бажаний результат.

Цей приклад може комусь здатись кумедним і відірваним від реальності. Але якщо змінити «IDuck», скажімо, на «ILogger», і в вашому коді потрібно використовувати логгер, для якого треба додати прекондишен, то приклад з качечками набуває сенсу. Головне, що хотілось показати: щоразу, коли вам потрібно робити приведення абстракції до якоїсь конкретної реалізації, це ознака того, що з архітектурою виникли проблеми, і варто подумати над її зміною.

Наостанок

Дякую за увагу до цієї статті. Сподіваюся, ці знання допоможуть вам не допускати помилок під час використанні наслідування і гарно відповідати на співбесідах про принцип підстановки Лісков.

У цій статті було використано декілька прикладів із тестами, якщо виникають запитання з цього приводу, можете подивитись мою попередню статтю про Юніт-тестування: dou.ua/forums/topic/40477.

І також не забувайте донатити на ЗСУ. Все буде Україна.

Підписуйтеся на Telegram-канал @gamedev_dou, щоб не пропустити найважливіші статті і новини

👍ПодобаєтьсяСподобалось12
До обраногоВ обраному2
LinkedIn

Схожі статті



Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Тема не розкрита.
Автор певно добре знає такий ресурс як learn.microsoft, але так і не навчився тієї подачі матеріалу.
Нібито приклад з квадратом, та ще й з умовами порушуючи принцип solid.
Ну так прямо в тексті уважний читач знайшов би рефакторінг і елегантне рішення для класу квадрат і кондішенів, які вже не порушують принципи.
Але того нема.

і кондішенів, які вже не порушують принципи.

Не завжди, мʼяко кажучи, таке можливо.

Дякую за статтю, але на початку автор, здається, зробив невелику помилку, змішавши опис Агрегації і приклад для Асоціації (відповідно, не розкривши різницю між цими поняттями)

Дякую за зауважженя, поправив цей момент)

Підписатись на коментарі