ООП
July 5, 2024

Наследование, абстрактные классы и интерфейсы

Парадигма наследования

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

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

  • Базовый класс (родительский) - класс, от которого поля и методы наследуются другим классом.
  • Производный класс (дочерний) — класс, унаследованный от базового класса.

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

Пример наследования класса Car от базового класса Vehicle
public class Vehicle
{
    public int MaxSpeed { get; set; }
    public int YearOfIssue { get; set; }
     
    public void Move() 
    {
        Console.WriteLine("I'm moving")
    }
}

public class Car : Vehicle
{
    public int CountWeels { get; set; }
    public int HpEngine { get; set; }
}

public class Program
{
    public static void Main()
    {
        var bmw = new Car 
        {
            MaxSpeed = 500,
            HpEngine = 1000,
            YearOfIssue = 1,
            CountWeels = 4,
        };
        
        bmw.Move();
    }
}

Таким образом, используя наследование нам не нужно создавать один и тот же код в разных классах, мы используем уже написанное и не нарушаем принцип разработки ПО DRY (dont repeat yourself).

Многоуровневая цепочка наследования

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

Мы можем добавить в наш пример класс грузовика Truck, который бы наследовался от Car и имел некую грузоподъемность прицепа, это будет являться многоуровневым наследованием.

public class Vehicle
{
    public int MaxSpeed { get; set; }
    public int YearOfIssue { get; set; }
     
    public void Move() 
    {
        Console.WriteLine("I'm moving")
    }
}

public class Car : Vehicle
{
    public int CountWeels { get; set; }
    public int HpEngine { get; set; }
}

public class Truck : Car
{
    public int TrailerCapacity { get; set; }
}

Также стоит помнить, что все классы неявно наследуются от базового класса Object, от него все классы и наследуют базовые методы ToString(), GetHashCode(), GetType(), Equals().

Доступ к членам базового класса

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

Если кратко, то класс наследник может иметь доступ только к членам базового класса, которые определены с модификаторами public, protected, internal (доступ в любом месте кода той же сборки), private protected (доступ в базовом классе и классах наследниках в той же сборке), protected internal (доступ в той же сборке и в классах наследниках любой сборки). К членам со спецификатором private можно получить доступ только в том же классе, обратиться к ним из классов наследников не получится.

public class Robot
{
    private int beerCount;

    public void Drink() 
    {
        Console.WriteLine(quot;bimbimbambam, I can have a drink {beerCount} beer bottles");
    }
}

public class Bender : Robot
{
    public void SomeMethod()
    {
        // Доступ к private полю beerCount из производного класса запрещен
        // beerCount = 10; Error
        Drink();  // Вызов Drink из производного класса
    }
}

Вызов конструктора базового класса

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

В примере выше все ок, и при создании объекта класса Bender будет вызван его конструктор по умолчанию и конструктор по умолчанию базового класса Robot (при чем в порядке наследования, начиная от базового). Но давайте смоделируем ситуацию, когда у Robot нет конструктора по умолчанию.

public class Robot
{
    private int intelligenceLevel;
    
    public Robot(int intelligenceLevel)
    {
        this.intelligenceLevel = intelligenceLevel;
    }
}

public class Bender : Robot
{
    private int beerCount;
    
    public Bender(int intelligenceLevel, int beerCount)
        : base(intelligenceLevel)
    {
        this.beerCount = beerCount;
    }

    public void Drink() 
    {
        Console.WriteLine(quot;bimbimbambam, I can have a drink {beerCount} beer bottles");
    }
}

В примере мы сделали некий класс Robot, у которого есть единственный конструктор, принимающий параметр некий уровень интеллекта intelligenceLevel, то есть конструктора по умолчанию у него больше нет (в таком случае мы можем явно прописать конструктор без параметров). Если при наследовании в классе Bender не вызвать данный конструктор базового класса через base, как показано, передав в него необходимые аргументы, то будет ошибка компиляции (то есть в унаследованных классах от Robot обязательно необходимо явно прописывать конструктор даже если он без параметров, чтобы вызывать конструктор базового класса и передать в него некоторые значения).

Порядок вызова конструкторов и финализаторов в цепочке наследования

При создании объекта класса сначала отрабатывают конструкторы базовых классов и только затем конструкторы производных (начиная с Object). При финализации объекта класса процесс происходит в обратном порядке.

public class Robot
{
    public Robot()
    {
        Console.WriteLine("Robot created");
    }
    
    ~Robot()
    {
        Console.WriteLine("Robot finalized");
    }
}

public class Bender : Robot
{
    public Bender()
    {
        Console.WriteLine("bimbimbambam, Bender created");
    }
    
    ~Bender()
    {
        Console.WriteLine("bambambimbim, Bender finalized");
    }
}

public class Program
{
    public static void Temp()
    {
        var bender = new Bender();
    }

    public static void Main()
    {

        Temp();
        GC.Collect();
    }
}

// Вывод:
// Robot created
// bimbimbambam, Bender created
// bambambimbim, Bender finalized
// Robot finalized

Запрет наследования

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

Для этих целей используется ключевое слово selead

// от класса робот наследование запрещено
public sealed class Robot
{
}

// ошибка компиляции 
public class Bender : Robot
{
}

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

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

Метод, который мы хотим сделать доступным для переопределения, в базовом классе помечаем ключевым словом virtual. А чтобы переопределить метод в классе наследнике, используем ключевое слово override. Андерстенд, ок?!

public class Robot
{
    public virtual void Drink()
    {
        Console.WriteLine("I'm an intelligent robot alcoholic");
    }
}

public class Bender : Robot
{
    private int beerCount;
    
    public Bender(int beerCount)
    {
        this.beerCount = beerCount;
    }

    public override void Drink() 
    {
        base.Drink(); // вызов метода базового класса
        Console.WriteLine(quot;bimbimbambam, I can have a drink {beerCount} beer bottles");
    }
}

public class Program
{
    public static void Main()
    {
        var bender = new Bender(10);
        bender.Drink();
    }
}

// Вывод:
// I'm an intelligent robot alcoholic
// bimbimbambam, I can have a drink 10 beer bottles

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

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

public class Robot
{
    public virtual void Drink()
    {
        Console.WriteLine("I'm an intelligent robot alcoholic");
    }
}

public class Bender : Robot
{
    private int beerCount;
    
    public Bender(int beerCount)
    {
        this.beerCount = beerCount;
    }

    public override sealed void Drink() 
    {
        base.Drink(); // вызов метода базового класса
        Console.WriteLine(quot;bimbimbambam, I can have a drink {beerCount} beer bottles");
    }
}

public class SuperBender : Bender
{
    public SuperBender(int beerCount)
        : base(beerCount)
    {
    }
    
    // Ошибка компиляции,переопределять этот метод нельзя
    public override void Drink()
    {
        Console.WriteLine("wabulabudabda");
    }
}

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

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

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

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

public abstract class Vehicle
{
    public int Carrying { get; set; }
    
    public abstract void Move();
}

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

public class Car : Vehicle
{
    public override void Move()
    {
        Console.WriteLine("Машина едет со скоростью n");
    }
}

public class Trolleybus
{
    public override void Move()
    {
        Console.WriteLine("Тролейбус едет со скоростью x");
    }
}

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

Интерфейсы

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

public interface IMovable
{
    void Move();
}

public class Car : IMovable
{
    public override void Move()
    {
        Console.WriteLine("Машина едет");
    }
}

public class Ship : IMovable
{
    public override void Move()
    {
        Console.WriteLine("Корабль идет");
    }
}

Начиная с C# 8, интерфейсы поддерживают реализацию методов по умолчанию, которая может переопределяться или не переопределяться в классе реализации. Помимо этого с C# 8 для интерфейсов стало доступно определение у членов спецификатора доступа (по умолчанию - public, в отличии от классов), константы, статические поля, статические методы и свойства, статические ивенты и индексаторы, статические конструкторы.

public interface IMovable
{
    void Move()
    {
        Console.WriteLine("Я могу перемещаться");
    }
}

public class Car : IMovable
{
    public override void Move()
    {
        Console.WriteLine("Машина едет");
    }
}

public class Bender : IMovable
{
}

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

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

public class Program
{
    public static void Main()
    {
        // Bender bender = new Bender();
        // bender.Move(); // ошибка компиляции!!!
        
        IMovable bender2 = new Bender();
        bender2.Move(); // Я могу перемещаться
    }
}

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

Множественное наследование

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

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

Таким образом, при создании объекта класса D по логике в памяти будет создано аж 2 объекта базового класса A, и в D будет унаследованы переопределения его методов и от B и от C, что является полной шизофренией.

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

public interface IMovable
{
    void Move()
    {
        Console.WriteLine("Я могу перемещаться");
    }
}

public interface IDrinker
{
    void Drink();
}

public class Bender : IMovable, IDrinker
{
    public override void Move()
    {
        Console.WriteLine("Я куда-то иду");
    }
    
    public override void Drink()
    {
        Console.WriteLine("бимбимбамбам, пива много не бывает");
    } 
}

Разница между абстрактными классами и интерфейсами

Давайте подведем итоги по основным отличиям абстрактных классов от интерфейсов и определим, когда что лучше использовать, потому что это не совсем очевидно, особенно после нововведений в C# 8 (нововведения... сейчас C# 12 уже, мда, в плюсах такой проблемы нет:))).

Отличия интерфейсов и абстрактных классов:

  • В абстрактных классах, как и в обычных классах, спецификатор доступа к членам по умолчанию - private, а в интерфейсах - public.
  • Интерфейсы в отличие от абстрактных классов могут иметь только статические конструкторы, т.к. в отличие от абстрактных классах в памяти не будет создаваться объект и у интерфейса нет никакого состояния, которое создается в конструкторе.
  • Возможность переиспользования кода в интерфейсах весьма ограничена, т.к. реализация по умолчанию доступна для вызова только от лица интерфейса.
  • Интерфейс может расширять множество других интерфейсов и классы могут реализовывать также более одного интерфейса. С абстрактным классом логика наследования такая же, как и с обычными классами, то есть множественное наследование от абстрактных классов запрещено.
  • Структуры могут реализовывать интерфейсы, но не могут наследоваться от абстрактных классов.

Таким образом, используем интерфейс, если:

  • Необходимо определить общий функционал (протокол) для различных классов объектов, которые слабо связаны между собой. (IClonable, IDisposable, IEnumerable, все эти базовые интерфейсы определяют некоторый функционал/контракт/протокол, которому соответствуют реализующие их классы)

Используем абстрактные классы, если:

  • Нам необходимо разработать общий функционал для родственных объектов, как некую абстрактную сущность (которую можно в отличие от интерфейса IClonable представить, например, Vehicle).
  • Классы наследники нуждаются в большом количестве общей функциональной логики или её предполагается часто менять. При этом достаточно изменить логику в классе родителе, чтобы она изменилась во всех классах наследниках.
  • Если класс сам по себе не является полноценной единицей, с которой можно взаимодействовать, то используем абстрактный класс, а не обычный.


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

Подписывайтесь на мой канал в тг, там говорят правду! В следующей статье раскроем тему полиморфизма.