В моем последнем посте я обещал рассказать о некоторых случаях, в которых, я думаю, имеет смысл рассмотреть использование дефолтной реализации в интерфейсах. Эта фича, конечно, не отменяет множество уже существующих соглашений по написанию кода, но я обнаружил, что в некоторых ситуациях использование дефолтной реализации приводит к более чистому и читаемому коду (по крайней мере, на мой взгляд).
В документации написано:
Рассмотрим пример:
Если я хочу добавить новый GetTopSpeed() метод в этот интерфейс, мне нужно добавить его имплементацию в Avalon:
Однако, если я создам дефолтную реализацию метода GetTopSpeed() в ICar, то у меня не будет необходимости добавлять его в каждый наследующийся класс.
При необходимости, я все ещё могу перегрузить реализацию в классах, для которых не подходит дефолтная:
Важно учитывать, что дефолтный метод GetTopSpeed() будет доступен только для переменных, приведенных к ICar и не будет доступен для Avalon, если в нём нет перегрузки. Это означает, что эта техника наиболее полезна в случае, если вы работаете именно с интерфейсами (иначе ваш код заполонит множество приведений к интерфейсам для получения доступа к дефолтной имплементации метода).
Похожие языковые концепции миксинов и трейтов описывают способы расширения поведения объекта путем композиции без необходимости множественного наследования.
Википедия сообщает о миксинах следующее:
Но, всё-таки, даже с дефолтной реализацией, интерфейсы в C# не являются миксинами. Отличие в том, что они так же могут содержать и методы без имплементации, поддерживают наследование от других интерфейсов, могут быть специализированы (видимо, имеются в виду ограничения шаблонов. — прим. перев.) и так далее. Однако, если мы сделаем интерфейс, который содержит только методы с реализацией по умолчанию, — это будет, по сути, традиционный миксин.
Рассмотрим следующий код, который добавляет объекту функционал «движения» и отслеживания его местоположения (например, в геймдеве):
Ой! В этом коде есть проблема, которую я не замечал до тех пор, пока не начал писать этот пост и не попытался скомпилировать пример. Интерфейсы (даже те, которые имеют дефолтную реализацию) не могут хранить состояние. Следовательно, интерфейсы не поддерживают автоматические свойства. Из документации:
Таким образом мы достигли желаемого, сделав метод Move() и его реализацию доступной всем классам, которые реализуют интерфейс IMovable. Конечно, классу все ещё нужно предоставить реализацию для свойств, но, по крайней мере, они объявлены в IMovable интерфейсе, что позволяет дефолтной реализации Move() с ними работать и гарантирует, что любой класс, реализующий интерфейс, будет иметь корректное состояние.
Как более полный и практический пример, рассмотрим миксин для логгирования:
Теперь в любом классе я могу унаследоваться от ILogger интерфейса:
И такой код:
Выведет:
Самое полезное применение, которое я нашел, это замена большого количества методов-расширений. Давайте вернемся к простому примеру логгирования:
До появления дефолтной имплементации в интерфейсах, я бы, как правило, написал множество методов-расширений к этому интерфейсу, чтобы в унаследованном классе нужно было реализовать только один метод, в результате чего пользователи получили бы доступ к множеству расширений:
Этот подход отлично работает, но не лишен недостатков. Например, пространства имен класса с расширениями и интерфейса не обязательно совпадают. Плюс раздражает визуальный шум в виде параметра и ссылки на экземпляр логгера:
Теперь я могу заменить расширения дефолтными реализациями:
Я нахожу такую имплементацию более чистой и удобной для чтения (и поддержки).
Использование реализации по умолчанию также имеет ещё несколько преимуществ перед методами-расширениями:
Что меня смущает в коде выше, так это то, что не совсем очевидно, какие члены интерфейса имеют дефолтную реализацию, а какие являются частью контракта, который должен реализовывать унаследовавшийся класс. Комментарий, разделяющий два блока, мог бы помочь, но мне в этом плане больше нравится строгая ясность методов-расширений.
Чтобы решить эту проблему, я начал объявлять интерфейсы, имеющие члены с реализацией по умолчанию, как partial (кроме разве что совсем простых). Затем я кладу дефолтные реализации в отдельный файл с конвенцией именования вида «ILogger.LogInfoDefaults.cs», «ILogger.LogErrorDefaults.cs» и так далее. Если дефолтных реализаций немного и нет необходимости в дополнительной группировке, то я именую файл «ILogger.Defaults.cs».
Это разделяет члены с дефолтной реализацией от неимплементированного контракта, который обязаны реализовывать унаследовавшиеся классы. Кроме того, это позволяет сократить очень длинные файлы. Ещё существует хитрый трюк с визуализацией вложенных файлов в стиле ASP.NET в проектах любого формата. Для этого добавьте в файл проекта или в Directory.Build.props:
Теперь вы можете выбрать «File Nesting» в Solution Explorer и все ваши .Defaults.cs файлы отобразятся как потомки «основного» файла интерфейса.
В заключение, все ещё есть несколько ситуаций, в которых предпочтительны методы-расширения:
Расширение интерфейсов с сохранением обратной совместимости
В документации написано:
Самый распространенный сценарий — это безопасное добавление методов в интерфейс, уже опубликованный и использующийся бесчисленным множеством клиентовРешаемая проблема заключается в том, что каждый класс, унаследованный от интерфейса, обязан предоставить реализацию для нового метода. Это не очень затруднительно, когда интерфейс используется только вашим собственным кодом, но если он находится в публичной библиотеке или используется другими командами, то добавление нового элемента интерфейса может вылиться в большую головную боль.
Рассмотрим пример:
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)