Интерфейс или абстрактный класс
Классический вопрос: «Интерфейс или абстрактный класс — что выберете и почему?» Ответить таблицей из учебника — верный способ провалить его.
Интерфейс — контракт поведения без состояния. Абстрактный класс — незаконченная реализация вместе с состоянием. Первый отвечает на вопрос «что объект умеет», второй — «чем объект является».
Как вопрос звучит на самом деле
Интервьюер редко спрашивает в лоб. Обычно он даёт задачку: «Есть фигуры: прямоугольник, квадрат, треугольник. Все умеют считать площадь. Вы сделаете Shape абстрактным классом или интерфейсом IShape?»
Плохой ответ: «Интерфейсом, потому что интерфейсы гибче». Хороший ответ начинается со встречного вопроса: есть ли у фигур общее состояние и общая реализация? Если да — абстрактный класс. Если нет — интерфейс. Всё остальное следует из этого.
Абстрактный класс: когда есть что переиспользовать
У всех фигур есть имя (состояние) и одинаковый способ себя описать (реализация). Значит, есть общий «кусок класса», который не хочется копировать в каждого наследника.
using System;
using System.Collections.Generic;
abstract class Shape
{
public string Name { get; }
protected Shape(string name)
{
Name = name;
}
public abstract int Area();
public void Describe()
{
Console.WriteLine($"{Name}: площадь = {Area()}");
}
}
class Rect : Shape
{
private readonly int _w;
private readonly int _h;
public Rect(int w, int h) : base("Прямоугольник")
{
_w = w;
_h = h;
}
public override int Area() => _w * _h;
}
class Square : Shape
{
private readonly int _side;
public Square(int side) : base("Квадрат")
{
_side = side;
}
public override int Area() => _side * _side;
}
class Program
{
static void Main()
{
var shapes = new List<Shape> { new Rect(2, 3), new Square(4) };
foreach (var shape in shapes)
{
shape.Describe();
}
}
}
Результат:
Прямоугольник: площадь = 6
Квадрат: площадь = 16
Обратите внимание на три вещи, которые интерфейс дать не может: Shape хранит состояние (Name), у него есть конструктор, который это состояние заполняет, и он содержит готовый метод Describe(), написанный один раз на всех. Метод Area() помечен abstract — тела нет, наследник обязан его дать.
Ещё деталь, которую любят проверять: конструктор абстрактного класса делают protected, а не public. Создать new Shape("...") всё равно нельзя — компилятор запретит, — но protected честно сообщает читателю: «я нужен только наследникам».
Интерфейс: когда важна роль, а не происхождение
Интерфейс описывает способность, которая может встретиться у совершенно неродственных классов. Утка летает и плавает; самолёт летает, но не плавает; подводная лодка плавает, но не летает. Общего предка у них нет — и не надо.
using System;
interface IFlyable
{
void Fly();
}
interface ISwimmable
{
void Swim();
}
class Duck : IFlyable, ISwimmable
{
public void Fly() => Console.WriteLine("Утка летит");
public void Swim() => Console.WriteLine("Утка плывёт");
}
class Program
{
static void Main()
{
var duck = new Duck();
IFlyable flyer = duck;
ISwimmable swimmer = duck;
flyer.Fly();
swimmer.Swim();
Console.WriteLine(flyer is ISwimmable);
}
}
Результат:
Утка летит
Утка плывёт
True
Класс наследуется ровно от одного класса, но реализует сколько угодно интерфейсов. Это единственное жёсткое техническое различие — и из него растёт вся остальная разница.
Коллизия имён и явная реализация
А что, если два интерфейса требуют метод с одинаковой сигнатурой, но разным смыслом? Это любимый добивающий вопрос.
using System;
interface ILogger
{
void Write(string message);
}
interface IReporter
{
void Write(string message);
}
class Service : ILogger, IReporter
{
void ILogger.Write(string message) => Console.WriteLine("[log] " + message);
void IReporter.Write(string message) => Console.WriteLine("[report] " + message);
}
class Program
{
static void Main()
{
var service = new Service();
// service.Write("..."); — не скомпилируется: публичного Write у Service нет
((ILogger)service).Write("сервис запущен");
((IReporter)service).Write("сервис запущен");
}
}
Результат:
[log] сервис запущен
[report] сервис запущен
Это явная реализация интерфейса (explicit interface implementation): метод пишется без модификатора доступа и с именем интерфейса впереди. Он не виден на самом объекте — только через приведение к нужному интерфейсу. Тот же приём используют, когда метод интерфейса «портит» публичный API класса и его хотят спрятать.
Default-реализации в интерфейсах (C# 8+)
Начиная с C# 8 интерфейс может содержать метод с телом. Это называется default interface method (DIM).
interface INotifier
{
void Send(string message);
// default-реализация: наследник может её не писать
void SendUrgent(string message) => Send("СРОЧНО: " + message);
}
class EmailNotifier : INotifier
{
public void Send(string message) => Console.WriteLine("email: " + message);
}
Зачем это придумали? Ради версионирования библиотек. Раньше добавление метода в опубликованный интерфейс ломало сборку у всех, кто его реализовал. Теперь можно добавить метод с разумным поведением по умолчанию — и чужой код продолжит компилироваться.
И сразу ловушка, на которой валятся: default-метод не становится методом класса. Он виден только через ссылку на интерфейс.
INotifier notifier = new EmailNotifier();
notifier.SendUrgent("база недоступна"); // OK — вызов через интерфейс
var direct = new EmailNotifier();
// direct.SendUrgent("база недоступна"); // ОШИБКА КОМПИЛЯЦИИ: у EmailNotifier нет такого метода
Поэтому на вопрос «раз интерфейсы теперь умеют реализацию, абстрактные классы больше не нужны?» правильный ответ — нужны. У интерфейса по-прежнему нет полей экземпляра, нет конструктора и негде хранить состояние. DIM — это инструмент эволюции API, а не замена наследованию реализации.
Как это работает под капотом
У каждого типа в CLR есть таблица методов (method table) и фиксированная раскладка полей в памяти. Наследование класса — это расширение уже существующей раскладки: поля базового класса идут первыми, слоты виртуальных методов наследуются как есть. Именно поэтому базовый класс может быть только один: склеить две раскладки полей и две таблицы методов без конфликтов невозможно (та самая «проблема ромба»).
Интерфейс не добавляет ни полей, ни базовой раскладки. Для каждой пары «класс + интерфейс» среда строит interface map — таблицу соответствия «слот интерфейса → конкретный метод класса». Конфликтовать тут нечему, поэтому интерфейсов можно реализовать сколько угодно. А при коллизии имён вы просто вручную заполняете эту карту явной реализацией.
Явная реализация компилируется в закрытый метод со специальным именем плюс запись в interface map — отсюда и правило «снаружи не видно, только через приведение». Default-метод физически лежит в самом интерфейсе, у класса такого слота нет — отсюда правило «работает только через ссылку на интерфейс». Вызов через интерфейс чуть дороже вызова через класс (лишний уровень косвенности), но JIT умеет девиртуализовать такие вызовы, так что в реальном коде это шум, а не аргумент.
Таблица-шпаргалка
| Возможность | Интерфейс | Абстрактный класс |
| Сколько можно применить к классу | сколько угодно | ровно один |
| Поля экземпляра, состояние | нет | да |
| Конструктор | нет | да (обычно protected) |
| Реализация метода | да, с C# 8 (default) | да, всегда |
| Модификаторы доступа у членов | по умолчанию public | любые, включая protected |
| Смысл | роль, способность («умеет летать») | сущность, общий предок («это фигура») |
Частые ошибки на собеседовании
- Ответ заученной таблицей. «Интерфейс — контракт, абстрактный класс — частичная реализация, интерфейсов много, класс один» — и тишина. Интервьюер ждёт критерий выбора, а не список отличий.
- «Интерфейс не может содержать реализацию». Это устарело в 2019 году вместе с C# 8. Скажете так — покажете, что не следите за языком.
- «Раз есть DIM, абстрактные классы не нужны». Обратная крайность. Нет полей, нет конструктора, нет состояния — DIM решает задачу версионирования API, а не наследования реализации.
- Не знать про явную реализацию. Вопрос «а если два интерфейса требуют метод с одинаковой сигнатурой?» задают именно чтобы отделить тех, кто писал код, от тех, кто читал статьи.
- Наследовать «ради переиспользования кода». Если общий предок нужен только чтобы не копировать метод, — часто честнее вынести логику в отдельный класс и внедрить его (композиция), а не тащить наследование.
- Забыть про тестируемость. Практический аргумент за интерфейс, который очень любят слышать: зависимости мокаются, если объявлены интерфейсом.
Итоги-шпаргалка
- Нужно общее состояние и/или общая реализация — абстрактный класс. Нужна роль, которую разделяют неродственные типы, — интерфейс.
- Класс — один, интерфейсов — сколько угодно. Это единственное непреодолимое ограничение.
- Коллизия имён интерфейсов решается явной реализацией:
void ILogger.Write(...)без модификатора доступа. - C# 8 дал интерфейсам default-реализации — но их видно только через ссылку на интерфейс, и состояние хранить всё равно негде.
- Формула ответа: «Зависит от того, есть ли общее состояние. Если есть — абстрактный класс; если это просто способность — интерфейс; часто нужны оба: интерфейс для контракта, абстрактный класс как база для типичной реализации».