JavaRush /Курси /C# SELF /Відмінності інтерфейсів і абстрактних класів

Відмінності інтерфейсів і абстрактних класів

C# SELF
Рівень 23 , Лекція 1
Відкрита

1. Дві сторони однієї медалі

Ми з вами вже досить глибоко занурилися в поняття абстракції й розглянули два її найпотужніші інструменти в C# — абстрактні класи та інтерфейси. Ви, мабуть, уже відчуваєте, що вони схожі, адже обидва дозволяють визначити «скелет» поведінки, яку потім мають реалізувати конкретні класи. Але повірте, це як порівнювати молоток і викрутку: обидва — інструменти, проте для різних завдань.

Почнімо з головного: навіщо нам два інструменти для одного завдання? У програмуванні, як і в житті, рідко буває «просто так». Якщо є два схожі інструменти, то в них є свої унікальні сильні сторони й області застосування.

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

Характеристика Абстрактний клас Інтерфейс
Створення екземпляра Не можна створити напряму (
new AbstractClass()
).
Не можна створити напряму (
new IMyInterface()
).
Реалізація членів Може мати:
– Повністю реалізовані методи/властивості.
– Абстрактні методи/властивості (без реалізації).
– Поля, конструктори, статичні методи.
Може мати методи з реалізацією за замовчуванням, статичні абстрактні та статичні неабстрактні члени.
Не може мати поля екземпляра і конструктори.
Модифікатори доступу Може мати будь-які (public, protected, internal, private). Усі члени інтерфейсу за замовчуванням public. Модифікатори доступу для них не вказуються (винятки: методи з реалізацією за замовчуванням і статичні члени).
Наслідування Клас може успадковуватися лише від одного абстрактного класу. Клас може реалізовувати багато інтерфейсів.
Тип зв’язку «Є» (is-a). Визначає базовий тип і спільну частину ієрархії. «Може робити» (has-a або can-do). Визначає контракт поведінки/здібності.
Стан Може зберігати стан (поля екземпляра). Може мати статичні поля, але не поля екземпляра.
Розширення Можна додавати нові реалізовані методи без зміни нащадків. Можна додавати методи з реалізацією за замовчуванням, не порушуючи роботу нащадків.

Вражає, чи не так? Розберімо ці пункти детальніше.

2. Множинне наслідування

Це, мабуть, найфундаментальніша й найпростіша для запам’ятовування відмінність. У C#, як і в багатьох інших мовах (наприклад, Java), клас може успадковуватися лише від одного базового класу. Чи то звичайний клас, чи абстрактний — неважливо, один і тільки один! Це зроблено, щоб уникнути знаменитої «проблеми ромба» (Diamond Problem), коли під час успадковування від кількох класів виникає неоднозначність: який із успадкованих методів використовувати, якщо вони мають однакову назву.

А ось інтерфейсів клас може реалізовувати скільки завгодно! Уявіть собі, що ваш клас — це певна людина. Вона може бути «студентом» (успадковується від класу Student), але водночас «уміє готувати» (ICookable), «уміє водити» (IDriveable) і «уміє співати» (ISingable). Це дуже гнучко!


abstract class Animal
{
    public string Name;
    public abstract void MakeSound();
    public void Eat() => Console.WriteLine($"{Name} їсть.");
}

interface IFlyable { void Fly(); double MaxFlyingAltitude { get; } }
interface ISwimable { void Swim(); }

class Duck : Animal, IFlyable, ISwimable
{
    public double MaxFlyingAltitude => 1000;
    public override void MakeSound() => Console.WriteLine($"{Name} крякає!");
    public void Fly() => Console.WriteLine($"{Name} летить!");
    public void Swim() => Console.WriteLine($"{Name} пливе!");
}

class Program
{
    static void Main()
    {
        var duck = new Duck { Name = "Дональд" };
        duck.MakeSound();
        duck.Eat();
        duck.Fly();
        duck.Swim();

        // Працюємо з обʼєктом через різні типи:
        Animal a = duck;  a.Eat();
        IFlyable f = duck; f.Fly();
        ISwimable s = duck; s.Swim();
    }
}

Якщо вашому класу потрібно бути частиною якоїсь ієрархії (наприклад, Dog є Animal), використовуйте успадковування від класу (абстрактного чи звичайного). Якщо класу потрібно мати якусь здатність (наприклад, Dog може бігати, Cat може бігати), але ці здібності не прив’язані до однієї суворої ієрархії, використовуйте інтерфейси. Це і є головна перевага інтерфейсів — вони дозволяють створювати контракти для дуже різних, не пов’язаних між собою класів.

3. Де живуть дані, а де лише обіцянки?

Абстрактні класи можуть містити все, що завгодно:

  • Звичайні (неабстрактні) методи й властивості з повною реалізацією.
  • Абстрактні методи й властивості без реалізації (їх ми й перевизначаємо).
  • Поля (змінні екземпляра), які зберігають стан об’єкта.
  • Конструктори, які використовуються для ініціалізації цього стану.
  • Навіть статичні методи й властивості.
  • І, звісно, вони можуть мати будь-які модифікатори доступу: public, protected, private тощо.

Це робить абстрактний клас потужним інструментом для визначення частково реалізованої поведінки і спільного стану для всіх його нащадків.


abstract class Employee
{
    public string FirstName, LastName;
    public decimal Salary { get; protected set; }
    public Employee(string first, string last) { FirstName = first; LastName = last; }
    public void GetPaid(decimal sum)
    {
        Salary += sum;
        Console.WriteLine($"{FirstName} {LastName} отримав {sum:C}. Зарплата: {Salary:C}");
    }
    public abstract void PerformWork();
    public abstract void TakeBreak();
}

class Developer : Employee
{
    public Developer(string f, string l) : base(f, l) { }
    public override void PerformWork() => Console.WriteLine($"{FirstName} пише код.");
    public override void TakeBreak() => Console.WriteLine($"{FirstName} пʼє каву.");
}

class Tester : Employee
{
    public Tester(string f, string l) : base(f, l) { }
    public override void PerformWork() => Console.WriteLine($"{FirstName} шукає баги.");
    public override void TakeBreak() => Console.WriteLine($"{FirstName} грає у футбол.");
}

class Program
{
    static void Main()
    {
        Employee[] team = {
            new Developer("Іван", "Петров"),
            new Tester("Марія", "Сидорова")
        };

        foreach (var emp in team)
        {
            Console.WriteLine($"\n--- Робочий день для {emp.FirstName} {emp.LastName} ---");
            emp.PerformWork();
            emp.GetPaid(2000);
            emp.TakeBreak();
        }
    }
}

Інтерфейси — це зовсім інша історія. До C# 8 вони могли містити тільки оголошення методів, властивостей, індексаторів і подій. Жодних полів, жодних конструкторів, жодної реалізації методів! Просто «підпис» методу без тіла. І всі їхні члени за замовчуванням були публічними (навіть якщо ви явно не писали public). Це жорстко гарантувало, що інтерфейс — це чистий контракт, без жодних деталей реалізації чи прихованого стану.

Починаючи з C# 8, інтерфейси стали трохи «товстішими» і отримали можливість мати методи з реалізацією за замовчуванням (Default Interface Methods) і статичні члени. Це зроблено для того, щоб можна було додавати нові методи в уже наявні інтерфейси, не «ламаючи» при цьому мільйони рядків коду, які їх реалізують. Але навіть попри ці нововведення, інтерфейси досі не можуть мати поля екземпляра і конструктори. Це ключове обмеження зберігається, щоб інтерфейси залишалися «контрактами поведінки», а не «сховищами стану».


interface ISaveable
{
    void Save(string file); 
    bool IsDirty { get; }
}

interface ILoadable
{
    void Load(string file);
}

class GameProgress : ISaveable, ILoadable
{
    public int Level { get; set; }
    public string PlayerName { get; set; }
    bool _isDirty = true;
    public bool IsDirty => _isDirty;

    public GameProgress(string name, int level)
    {
        PlayerName = name; Level = level;
    }
    public void Save(string file)
    {
        Console.WriteLine($"Зберіг: {PlayerName}, рівень {Level} -> {file}");
        _isDirty = false;
    }
    public void Load(string file)
    {
        Console.WriteLine($"Завантажено з {file}");
        PlayerName = "Новий гравець"; Level = 5; _isDirty = true;
    }
    public void UpdateProgress(int newLevel)
    {
        Level = newLevel; _isDirty = true;
        Console.WriteLine($"Оновлено до {Level}.");
    }
}

class Program
{
    static void Main()
    {
        var game = new GameProgress("Герой", 1);
        game.UpdateProgress(3);

        ISaveable saver = game;
        if (saver.IsDirty) saver.Save("save.dat");

        ILoadable loader = game;
        loader.Load("save.dat");
        Console.WriteLine($"Після завантаження: {game.PlayerName}, {game.Level}");
    }
}

Висновок: Якщо вам потрібен базовий клас, що надає не лише контракт, а й уже реалізований код (загальну логіку) або зберігає спільний стан, обирайте абстрактний клас. Якщо потрібен просто «чек-ліст» чи «договір» поведінки без жодної реалізації чи стану, інтерфейс — ваш вибір.

4. Коли використовувати інтерфейси?

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

  • IDisposable: будь-який об’єкт, який потрібно коректно «звільнити» після використання (файл, мережеве з’єднання, база даних).
  • IEnumerable<T>: будь-який об’єкт, який можна перебирати в циклі foreach.
  • IComparable<T>: будь-який об’єкт, який можна порівнювати з іншим об’єктом того самого типу.

Важливо те, що FileStream і SqlConnection можуть реалізовувати IDisposable, а List<T> і Dictionary<TKey, TValue> можуть реалізовувати IEnumerable<T>. Ці класи належать до зовсім різних ієрархій, але вони мають спільну здатність, визначену інтерфейсом.

Приклад. Уявіть собі систему, де у вас є Машина і Літак. Обидва можуть бути ТранспортнимЗасобом (можливо, абстрактний клас). Але і Машина, і Літак, і навіть Човен (якщо ви додасте його) можуть рухатися. Ця здатність «рухатися» — чудовий кандидат для інтерфейсу IMovable.


interface IMovable
{
    void Move(int distance);
}

class Car : IMovable
{
    public string Brand;
    public Car(string brand) => Brand = brand;
    public void Move(int d) => Console.WriteLine($"{Brand} їде {d} км.");
}

class Airplane : IMovable
{
    public string Model;
    public Airplane(string model) => Model = model;
    public void Move(int d) => Console.WriteLine($"{Model} летить {d} км.");
}

class Human : IMovable
{
    public string Name;
    public Human(string name) => Name = name;
    public void Move(int d) => Console.WriteLine($"{Name} проходить {d} м.");
}

class Program
{
    static void Main()
    {
        IMovable[] movers = { new Car("Toyota"), new Airplane("Boeing 747"), new Human("Артур") };
        foreach (var item in movers)
            item.Move(100);
    }
}

Бачите? Ми можемо створити список IMovable і викликати метод Move() для кожного елемента, не знаючи, це Car, Airplane чи Human. Ось це і є сила поліморфізму через інтерфейси.

5. Коли використовувати абстрактні класи?

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

Уявімо, що у вас є група різних видів банківських рахунків: SavingAccount (ощадний), CheckingAccount (поточний), CreditAccount (кредитний). Усі вони є BankAccount. У всіх є баланс (Balance), і всі можуть поповнювати рахунок (Deposit). Але от правила зняття грошей (Withdraw) у кожного свої. Тут і стає у пригоді абстрактний клас BankAccount!


abstract class BankAccount
{
    public string AccountNumber { get; }
    public decimal Balance { get; protected set; }
    public BankAccount(string acc) { AccountNumber = acc; }
    public void Deposit(decimal sum)
    {
        if (sum > 0)
        {
            Balance += sum;
            Console.WriteLine($"{AccountNumber}: +{sum:C}, Баланс: {Balance:C}");
        }
    }
    public abstract bool Withdraw(decimal sum);
}

class CheckingAccount : BankAccount
{
    public CheckingAccount(string acc) : base(acc) { }
    public override bool Withdraw(decimal sum)
    {
        if (Balance >= sum)
        {
            Balance -= sum;
            Console.WriteLine($"{AccountNumber}: -{sum:C}, Баланс: {Balance:C}");
            return true;
        }
        Console.WriteLine($"{AccountNumber}: Недостатньо коштів");
        return false;
    }
}

class CreditAccount : BankAccount
{
    public decimal CreditLimit { get; }
    public CreditAccount(string acc, decimal limit) : base(acc) => CreditLimit = limit;
    public override bool Withdraw(decimal sum)
    {
        if (Balance - sum >= -CreditLimit)
        {
            Balance -= sum;
            Console.WriteLine($"{AccountNumber}: -{sum:C}, Баланс: {Balance:C}");
            return true;
        }
        Console.WriteLine($"{AccountNumber}: Перевищено ліміт");
        return false;
    }
}

class Program
{
    static void Main()
    {
        var checking = new CheckingAccount("12345");
        checking.Deposit(1000);
        checking.Withdraw(300);
        checking.Withdraw(800);

        Console.WriteLine("\n--- Кредитний рахунок ---");
        var credit = new CreditAccount("67890", 500);
        credit.Deposit(200);
        credit.Withdraw(400);
        credit.Withdraw(400);
    }
}

Тут BankAccount дає всім рахункам спільну логіку поповнення (Deposit) і керує номером рахунку та балансом. А логіка зняття (Withdraw) різна, тому вона абстрактна.

6. Коли вони працюють разом: ідеальна пара

Найелегантніший і найпотужніший дизайн часто включає комбінацію абстрактних класів та інтерфейсів. Абстрактний клас може сам реалізовувати один або кілька інтерфейсів!

Уявіть: у вас є abstract Animal, який визначає базові речі. Але деякі Animal можуть бути IMovable, ICarnivore, IPredator і так далі. Ваш Animal може навіть надавати базову реалізацію для IMovable (наприклад, метод Move(int speed)), а потім конкретні класи, такі як Lion чи Fish, перевизначатимуть її, щоб рухатися по-своєму.


public interface ISaveable
{
    void SaveState(string path);
    bool HasChanges { get; }
}

public class Vector3
{
    public float X, Y, Z;
    public Vector3(float x, float y, float z) { X = x; Y = y; Z = z; }
    public override string ToString() => $"({X}, {Y}, {Z})";
}

public abstract class GameObject
{
    public string Id { get; }
    public Vector3 Position { get; }
    protected GameObject(string id, Vector3 pos) { Id = id; Position = pos; }
    public abstract void Update();
    public void Destroy() => Console.WriteLine($"{Id} знищено");
}

public class Player : GameObject, ISaveable
{
    public int Health { get; private set; }
    private bool _hasChanges = true;
    public bool HasChanges => _hasChanges;

    public Player(string id, Vector3 pos, int health) : base(id, pos) => Health = health;

    public override void Update() =>
        Console.WriteLine($"{Id} оновлюється, HP: {Health}, Pos: {Position}");

    public void TakeDamage(int dmg)
    {
        Health -= dmg;
        _hasChanges = true;
        Console.WriteLine($"{Id} отримав {dmg} шкоди. HP: {Health}");
    }

    public void SaveState(string path)
    {
        Console.WriteLine($"Зберіг {Id} (HP:{Health}) у {path}");
        _hasChanges = false;
    }
}

class GameEngine
{
    static void Main()
    {
        var player = new Player("P1", new Vector3(0, 0, 0), 100);
        player.Update();
        player.TakeDamage(20);

        ISaveable saver = player;
        if (saver.HasChanges) saver.SaveState("save.json");

        GameObject obj = player;
        obj.Update();
        player.Destroy();
    }
}

У цьому прикладі Player є GameObject (бо успадковує його, використовуючи спільні поля Id, Position і метод Destroy). І водночас Player може бути збережений (бо реалізує інтерфейс ISaveable). Це дуже гнучкий і потужний підхід!

7. Нюанси та типові помилки

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

Animal myAnimal = new Animal();
— це часта помилка новачків. Пам’ятайте: абстрактний клас — це шаблон, а не готовий об’єкт. Компілятор відразу вкаже на це.

Забути реалізувати абстрактні методи: Якщо ви успадковуєтеся від абстрактного класу і ваш клас не є абстрактним, ви зобов’язані перевизначити (override) всі абстрактні методи базового класу. Інакше компілятор теж сваритиметься.

Спроба додати поля в інтерфейс: Це одне з тих «не можна», які чітко відрізняють інтерфейси. Починаючи з C# 8, з’явилися статичні поля, але поля екземпляра (тобто ті, що належать конкретному об’єкту) досі заборонені. Інтерфейс — це контракт поведінки, а не сховище даних.

Плутанина virtual і abstract:

  • virtual-метод/властивість має реалізацію за замовчуванням і може бути перевизначений у нащадках.
  • abstract-метод/властивість не має реалізації і повинен бути перевизначений у першому неабстрактному нащадку.
  • Використання virtual там, де логіка завжди різна, призводить до потреби писати порожні реалізації, а потім їх перевизначати. Це менш чіткий контракт, ніж abstract.

Розуміння цих відмінностей і правильний вибір між абстрактним класом та інтерфейсом — це ключова навичка для кожного C#-розробника. Це не просто питання синтаксису, а питання архітектурного мислення. Коли ви проєктуєте систему, поставте собі запитання: «Чи мають ці класи спільну ієрархію “є”?» і «Чи мають ці класи спільну “здатність”, яка потрібна непов’язаним між собою типам?». Відповіді допоможуть вам зробити правильний вибір.

У наступних лекціях ми продовжимо занурюватися у світ інтерфейсів, вивчаючи їх більш просунуті можливості, що з’явилися в останніх версіях C#. Залишайтеся з нами!

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ