- PVSM.RU - https://www.pvsm.ru -

Для чего пригодится дефолтная реализация интерфейсов?

В моем последнем [1] посте я обещал рассказать о некоторых случаях, в которых, я думаю, имеет смысл рассмотреть использование дефолтной реализации в интерфейсах. Эта фича, конечно, не отменяет множество уже существующих соглашений по написанию кода, но я обнаружил, что в некоторых ситуациях использование дефолтной реализации приводит к более чистому и читаемому коду (по крайней мере, на мой взгляд).

Расширение интерфейсов с сохранением обратной совместимости

В документации написано:

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

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

Рассмотрим пример:

interface ICar
{
    string Make { get; }
}

public class Avalon : ICar
{
    public string Make => "Toyota";
}

Если я хочу добавить новый GetTopSpeed() метод в этот интерфейс, мне нужно добавить его имплементацию в Avalon:

interface ICar
{
    string Make { get; }
    int GetTopSpeed();
}

public class Avalon : ICar
{
    public string Make => "Toyota";
    public int GetTopSpeed() => 130;
}

Однако, если я создам дефолтную реализацию метода GetTopSpeed() в ICat, то у меня не будет необходимости добавлять его в каждый наследующийся класс.

interface ICar
{
    string Make { get; }
    public int GetTopSpeed() => 150;
}

public class Avalon : ICar
{
    public string Make => "Toyota";
}

При необходимости, я все ещё могу перегрузить реализацию в классах, для которых не подходит дефолтная:

interface ICar
{
    string Make { get; }
    public int GetTopSpeed() => 150;
}

public class Avalon : ICar
{
    public string Make => "Toyota";
    public int GetTopSpeed() => 130;
}

Важно учитывать, что дефолтный метод GetTopSpeed() будет доступен только для переменных, приведенных к ICar и не будет доступен для Avalon, если в нём нет перегрузки. Это означает, что эта техника наиболее полезна в случае, если вы работаете именно с интерфейсами (иначе ваш код заполонит множество приведений к интерфейсам для получения доступа к дефолтной имплементации метода).

Миксины и трейты (или типа того)

Похожие языковые концепции миксинов [2] и трейтов [3] описывают способы расширения поведения объекта путем композиции без необходимости множественного наследования.

Википедия сообщает о миксинах следующее:

Миксин так же может рассматриваться как интерфейс с реализованными по умолчанию методами

Звучит похоже?

Но, всё-таки, даже с дефолтной реализацией, интерфейсы в C# не являются миксинами. Отличие в том, что они так же могут содержать и методы без имплементации, поддерживают наследование от других интерфейсов, могут быть специализированы (видимо, имеются в виду ограничения шаблонов. — прим. перев.) и так далее. Однако, если мы сделаем интерфейс, который содержит только методы с реализацией по умолчанию, — это будет, по сути, традиционный миксин.

Рассмотрим следующий код, который добавляет объекту функционал «движения» и отслеживания его местоположения (например, в геймдеве):

public interface IMovable
{
    public (int, int) Location { get; set; }
    public int Angle { get; set; }
    public int Speed { get; set; }

    // Метод, изменяющий расположение исходя из направления и скорости движения
    public void Move() => Location = ...;
}

public class Car : IMovable
{
    public string Make => "Toyota";
}

Ой! В этом коде есть проблема, которую я не замечал до тех пор, пока не начал писать этот пост и не попытался скомпилировать пример. Интерфейсы (даже те, которые имеют дефолтную реализацию) не могут хранить состояние. Следовательно, интерфейсы не поддерживают автоматические свойства. Из документации [4]:

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

В этом C# интерфейсы и расходятся с концепцией миксинов (насколько я их понимаю, миксины концептуально могут хранить состояние), но мы все ещё можем достичь изначальной цели:

public interface IMovable
{
    public (int, int) Location { get; set; }
    public int Angle { get; set; }
    public int Speed { get; set; }

    // A method that changes location
    // using angle and speed
    public void Move() => Location = ...;
}

public class Car : IMovable
{
    public string Make => "Toyota";

    // Метод, изменяющий расположение исходя из направления и скорости движения
    public (int, int) Location { get; set; }
    public int Angle { get; set; }
    public int Speed { get; set; }
}

Таким образом мы достигли желаемого, сделав метод Move() и его реализацию доступной всем классам, которые реализуют интерфейс IMovable. Конечно, классу все ещё нужно предоставить реализацию для свойств, но, по крайней мере, они объявлены в IMovable интерфейсе, что позволяет дефолтной реализации Move() с ними работать и гарантирует, что любой класс, реализующий интерфейс, будет иметь корректное состояние.

Как более полный и практический пример, рассмотрим миксин для логгирования:

public interface ILogger
{
    public void LogInfo(string message) =>
        LoggerFactory
            .GetLogger(this.GetType().Name)
            .LogInfo(message);
}

public static class LoggerFactory
{
    public static ILogger GetLogger(string name) =>
        new ConsoleLogger(name);
}

public class ConsoleLogger : ILogger
{
    private readonly string _name;

    public ConsoleLogger(string name)
    {
        _name = name
        ?? throw new ArgumentNullException(nameof(name));
    }

    public void LogInfo(string message) =>
        Console.WriteLine($"[INFO] {_name}: {message}");
}

Теперь в любом классе я могу унаследоваться от ILogger интерфейса:

public class Foo : ILogger
{
    public void DoSomething()
    {
        ((ILogger)this).LogInfo("Woot!");
    }
}

И такой код:

Foo foo = new Foo();
foo.DoSomething();

Выведет:

[INFO] Foo: Woot!

Замена методов-расширений

Самое полезное применение, которое я нашел, это замена большого количества методов-расширений. Давайте вернемся к простому примеру логгирования:

public interface ILogger
{
    void Log(string level, string message);
}

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

public static class ILoggerExtensions
{
    public static void LogInfo(this ILogger logger, string message) =>
        logger.Log("INFO", message);

    public static void LogInfo(this ILogger logger, int id, string message) =>
        logger.Log("INFO", $"[{id}] message");

    public static void LogError(this ILogger logger, string message) =>
        logger.Log("ERROR", message);

    public static void LogError(this ILogger logger, int id, string message) =>
        logger.Log("ERROR", $"[{id}] {message}");

    public static void LogError(this ILogger logger, Exception ex) =>
        logger.Log("ERROR", ex.Message);

    public static void LogError(this ILogger logger, int id, Exception ex) =>
        logger.Log("ERROR", $"[{id}] {ex.Message}");
}

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

this ILogger logger
logger.Log

Теперь я могу заменить расширения дефолтными реализациями:

public interface ILogger
{
    void Log(string level, string message);

    public void LogInfo(string message) =>
        Log("INFO", message);

    public void LogInfo(int id, string message) =>
        Log("INFO", $"[{id}] message");

    public void LogError(string message) =>
        Log("ERROR", message);

    public void LogError(int id, string message) =>
        Log("ERROR", $"[{id}] {message}");

    public void LogError(Exception ex) =>
        Log("ERROR", ex.Message);

    public void LogError(int id, Exception ex) =>
        Log("ERROR", $"[{id}] {ex.Message}");
}

Я нахожу такую имплементацию более чистой и удобной для чтения (и поддержки).

Использование реализации по умолчанию также имеет ещё несколько преимуществ перед методами-расширениями:

  • Можно использовать this
  • Можно предоставлять не только методы, но и другие элементы: например, индексаторы
  • Реализация по умолчанию может быть перегружена для уточнения поведения

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

Чтобы решить эту проблему, я начал объявлять интерфейсы, имеющие члены с реализацией по умолчанию, как partial (кроме разве что совсем простых). Затем я кладу дефолтные реализации в отдельный файл с конвенцией именования вида «ILogger.LogInfoDefaults.cs», «ILogger.LogErrorDefaults.cs» и так далее. Если дефолтных реализаций немного и нет необходимости в дополнительной группировке, то я именую файл «ILogger.Defaults.cs».

Это разделяет члены с дефолтной реализацией от неимплементированного контракта, который обязаны реализовывать унаследовавшиеся классы. Кроме того, это позволяет сократить очень длинные файлы. Ещё существует хитрый трюк с визуализацией вложенных файлов в стиле ASP.NET в проектах любого формата. Для этого добавьте в файл проекта или в Directory.Build.props:

<ItemGroup>
  <ProjectCapability Include="DynamicDependentFile"/>
  <ProjectCapability Include="DynamicFileNesting"/>
</ItemGroup>

Теперь вы можете выбрать «File Nesting» в Solution Explorer и все ваши .Defaults.cs файлы отобразятся как потомки «основного» файла интерфейса.

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

  • Если вы обычно работаете с классами, а не интерфейсами (потому что вам придется приводить объекты к интерфейсам для доступа к дефолтным реализациям)
  • Если вы часто используете расширения с шаблонами: public static T SomeExt<T>(this T foo) (например, в Fluent API)

Автор: Lelushak

Источник [5]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/c-2/330669

Ссылки в тексте:

[1] моем последнем: https://daveaglick.com/posts/default-interface-members-and-inheritance

[2] миксинов: https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%B8%D0%BC%D0%B5%D1%81%D1%8C_(%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5)

[3] трейтов: https://ru.wikipedia.org/wiki/%D0%A2%D0%B8%D0%BF%D0%B0%D0%B6_(%D0%B0%D0%B1%D1%81%D1%82%D1%80%D0%B0%D0%BA%D1%82%D0%BD%D1%8B%D0%B9_%D1%82%D0%B8%D0%BF)

[4] документации: https://github.com/dotnet/csharplang/blob/master/proposals/csharp-8.0/default-interface-methods.md#detailed-design

[5] Источник: https://habr.com/ru/post/467949/?utm_campaign=467949&utm_source=habrahabr&utm_medium=rss