Интерфейсы и абстрактные классы, default interface methods

↑ 1.4 ООП в C♯ · ← Предыдущая · → Следующая

Обязательно~5 мин чтенияУровень: junior

Зачем это на собесе

интерфейсы — основа DI, тестируемости и слабой связанности. Вопрос «интерфейс или абстрактный класс» гарантирован. В проекте: ISagaContextAccessor, ISagaFinalizer, сервисы терминологии с реализациями grpc и pg.

Объяснение

Интерфейс — контракт

Описывает, что умеет тип, без того как. Класс или struct может реализовать много интерфейсов.

public interface ITerminologyService
{
    Task<Concept?> LookupAsync(string system, string code, CancellationToken ct);
    Task<IReadOnlyList<Concept>> ExpandAsync(string valueSetUrl, CancellationToken ct);
}
 
// две реализации одного контракта (как в проекте: services_terminology_implementation_grpc / _pg)
public sealed class PgTerminologyService(TerminologyDbContext db) : ITerminologyService { ... }
public sealed class GrpcTerminologyService(Terminology.TerminologyClient client) : ITerminologyService { ... }
 
// потребитель зависит от абстракции — реализацию выбирает DI
public sealed class DiagnosisValidator(ITerminologyService terminology) { ... }

Default interface methods (C# 8)

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

public interface IReport
{
    string Title { get; }
    byte[] Render(ReportData data);
    string FileName => $"{Title}_{DateTime.UtcNow:yyyyMMdd}.pdf";   // реализация по умолчанию
}

Нюанс: default-метод доступен только через ссылку типа интерфейса, не через класс. Ещё в интерфейсах можно: статические члены, static abstract (generic math, C# 11), приватные методы-хелперы.

Явная реализация интерфейса

public class AuditLog : IDisposable, IAsyncDisposable
{
    void IDisposable.Dispose() { ... }          // доступно только через IDisposable
    public ValueTask DisposeAsync() { ... }
}

Для разрешения конфликта одинаковых сигнатур из разных интерфейсов или чтобы скрыть член из публичного API класса.

Интерфейс vs абстрактный класс

  • Множественность: интерфейсов — много, базовый класс — один.
  • Состояние: интерфейс — без полей экземпляра, абстрактный класс — может хранить состояние и иметь конструктор.
  • Назначение: интерфейс — «умеет делать» (роль, контракт между модулями), абстрактный класс — «является» + общая реализация (шаблон алгоритма).
  • Эволюция: добавить член в интерфейс без default-реализации ломает все реализации, в абстрактный класс — можно с виртуальной реализацией.
  • Struct: может реализовать интерфейс, но не наследовать класс. Практика: публичные контракты сервисов — интерфейсы. Абстрактный базовый класс — когда много общей реализации (Template Method), и часто вместе: IReport + ReportBase : IReport.

Маркерные и ролевые интерфейсы

  • Маркерный (пустой) интерфейс — лучше заменить атрибутом.
  • Interface Segregation: много мелких ролевых интерфейсов лучше одного «толстого» (IReadRepository и IWriteRepository).

Нюансы и подводные камни

  • «Интерфейс на каждый класс» (IPatientService с единственной реализацией и без тестов) — церемония. Интерфейс оправдан, когда есть несколько реализаций, граница модуля или нужна подмена в тестах.
  • Приведение struct к интерфейсу вызывает boxing. В generic-коде используйте ограничения where T : IFoo — без боксинга.
  • Default interface methods — не замена базовых классов, у них нет состояния.
  • Интерфейс, который протекает деталями реализации (IUserRepository.GetDbContext()), теряет смысл абстракции.

Тестирование

Интерфейсы позволяют подменять зависимости:

[Fact]
public async Task Validator_rejects_unknown_code()
{
    var terminology = Substitute.For<ITerminologyService>();
    terminology.LookupAsync("icd10", "XXX", Arg.Any<CancellationToken>()).Returns((Concept?)null);
    var sut = new DiagnosisValidator(terminology);
    (await sut.IsValidAsync("icd10", "XXX", default)).Should().BeFalse();
}

Вопросы с ответами

Связанные темы