Programming
Perfect code
.NET
C#
20 September

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

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

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


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

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

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() в ICar, то у меня не будет необходимости добавлять его в каждый наследующийся класс.

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, если в нём нет перегрузки. Это означает, что эта техника наиболее полезна в случае, если вы работаете именно с интерфейсами (иначе ваш код заполонит множество приведений к интерфейсам для получения доступа к дефолтной имплементации метода).

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


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

Википедия сообщает о миксинах следующее:
Миксин так же может рассматриваться как интерфейс с реализованными по умолчанию методами
Звучит похоже?

Но, всё-таки, даже с дефолтной реализацией, интерфейсы в 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";
}

Ой! В этом коде есть проблема, которую я не замечал до тех пор, пока не начал писать этот пост и не попытался скомпилировать пример. Интерфейсы (даже те, которые имеют дефолтную реализацию) не могут хранить состояние. Следовательно, интерфейсы не поддерживают автоматические свойства. Из документации:
Интерфейсы не могут хранить состояние экземпляра. Не смотря на то, что статические поля в интерфейсах теперь разрешены, экземплярные поля использовать по-прежнему нельзя. Следовательно, нельзя использовать и автоматические свойства, так как они неявно используют скрытые поля.
В этом 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)

+12
4.1k 45
Comments 6
Top of the day