En
mi última publicación, prometí hablar sobre algunos casos en los que, creo, tiene sentido considerar el uso de implementaciones predeterminadas en las interfaces. Esta característica, por supuesto, no cancela muchas convenciones existentes para escribir código, pero descubrí que en algunas situaciones el uso de la implementación predeterminada conduce a un código más limpio y más legible (al menos en mi opinión).
Interfaces expansibles con compatibilidad hacia atrás
La documentación dice:
El escenario más común es agregar de forma segura métodos a una interfaz ya publicada y utilizada por innumerables clientes.
El problema a resolver es que cada clase heredada de la interfaz debe proporcionar una implementación para el nuevo método. Esto no es muy difícil cuando la interfaz es utilizada solo por su propio código, pero si está en la biblioteca pública o es utilizada por otros comandos, agregar un nuevo elemento de interfaz puede generar un gran dolor de cabeza.
Considere un ejemplo:
interface ICar { string Make { get; } } public class Avalon : ICar { public string Make => "Toyota"; }
Si quiero agregar un nuevo método
GetTopSpeed () a esta interfaz, necesito agregar su implementación en
Avalon :
interface ICar { string Make { get; } int GetTopSpeed(); } public class Avalon : ICar { public string Make => "Toyota"; public int GetTopSpeed() => 130; }
Sin embargo, si creo una implementación predeterminada del método
GetTopSpeed () en
ICar , no tendré que agregarla a cada clase heredada.
interface ICar { string Make { get; } public int GetTopSpeed() => 150; } public class Avalon : ICar { public string Make => "Toyota"; }
Si es necesario, todavía puedo sobrecargar la implementación en clases para las que el valor predeterminado no es adecuado:
interface ICar { string Make { get; } public int GetTopSpeed() => 150; } public class Avalon : ICar { public string Make => "Toyota"; public int GetTopSpeed() => 130; }
Es importante tener en cuenta que el método predeterminado
GetTopSpeed () estará disponible solo para variables convertidas en
ICar y no estará disponible para
Avalon si no tiene sobrecarga. Esto significa que esta técnica es más útil si está trabajando con interfaces (de lo contrario, su código se inundará con una gran cantidad de conversiones a las interfaces para obtener acceso a la implementación predeterminada del método).
Mixins y rasgos (o algo así)
Conceptos similares de lenguaje de
mixins y
rasgos describen formas de expandir el comportamiento de un objeto a través de la composición sin la necesidad de herencia múltiple.
Wikipedia informa lo siguiente sobre mixins:
Mixin también se puede considerar como una interfaz con métodos predeterminados
¿Suena eso así?
Pero, sin embargo, incluso con la implementación predeterminada, las interfaces en
C # no son mixins. La diferencia es que también pueden contener métodos sin implementación, admitir la herencia de otras interfaces, pueden ser especializados
(aparentemente, esto se refiere a restricciones de plantilla. - aprox. Transl.) Y así sucesivamente. Sin embargo, si creamos una interfaz que contenga solo métodos con una implementación predeterminada, de hecho, será un mixin tradicional.
Considere el siguiente código, que agrega funcionalidad al objeto "movimiento" y rastrea su ubicación (por ejemplo, en el desarrollo del juego):
public interface IMovable { public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; }
¡Ay! Hay un problema en este código que no noté hasta que comencé a escribir esta publicación e intenté compilar un ejemplo. Las interfaces (incluso aquellas que tienen una implementación predeterminada) no pueden almacenar el estado. Por lo tanto, las interfaces no admiten propiedades automáticas. De la
documentación :
Las interfaces no pueden almacenar el estado de la instancia. Aunque los campos estáticos ahora están permitidos en las interfaces, los campos de instancia todavía no se pueden usar. Por lo tanto, no puede usar propiedades automáticas, ya que implícitamente usan campos ocultos.
En este
C #, las interfaces están en desacuerdo con el concepto de mixins (por lo que yo entiendo, los mixins pueden almacenar conceptualmente el estado), pero aún podemos lograr el objetivo original:
public interface IMovable { public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; }
Por lo tanto, logramos lo que queríamos al hacer que el método
Move () y su implementación estén disponibles para todas las clases que implementan la interfaz
IMovable . Por supuesto, la clase aún necesita proporcionar una implementación para las propiedades, pero al menos se declaran en la interfaz
IMovable , lo que permite que la implementación predeterminada de
Move () funcione con ellas y garantiza que cualquier clase que implemente la interfaz tenga el estado correcto.
Como un ejemplo más completo y práctico, considere un mixin para iniciar sesión:
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}"); }
Ahora, en cualquier clase, puedo heredar de la interfaz
ILogger :
public class Foo : ILogger { public void DoSomething() { ((ILogger)this).LogInfo("Woot!"); } }
Y tal código:
Foo foo = new Foo(); foo.DoSomething();
Salidas:
[INFO] Foo: Woot!
Sustitución de métodos de extensión
La aplicación más útil que he encontrado es reemplazar una gran cantidad de métodos de extensión. Volvamos a un ejemplo de registro simple:
public interface ILogger { void Log(string level, string message); }
Antes de que aparecieran las implementaciones predeterminadas en las interfaces, como regla general, escribiría muchos métodos de extensión para esta interfaz, por lo que en una clase heredada solo necesitaría implementar un método, como resultado de lo cual los usuarios tendrían acceso a muchas extensiones:
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}"); }
Este enfoque funciona muy bien, pero no sin fallas. Por ejemplo, los espacios de nombres de clase con extensiones e interfaces no necesariamente coinciden. Más ruido visual molesto en forma de un parámetro y un enlace a una instancia de registrador:
this ILogger logger logger.Log
Ahora puedo reemplazar las extensiones con implementaciones predeterminadas:
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}"); }
Encuentro esta implementación más limpia y fácil de leer (y soporte).
El uso de la implementación predeterminada también tiene varias ventajas más sobre los métodos de extensión:
- Puede usar esto
- Puede proporcionar no solo métodos, sino también otros elementos: por ejemplo, indexadores
- La implementación predeterminada puede sobrecargarse para aclarar el comportamiento.
Lo que me confunde en el código anterior es que no es bastante obvio qué miembros de la interfaz tienen una implementación predeterminada y cuáles son parte del contrato que la clase heredada debería implementar. Un comentario que separe los dos bloques podría ayudar, pero me gusta la claridad estricta de los métodos de extensión a este respecto.
Para resolver este problema, comencé a declarar las interfaces que tienen miembros con la implementación predeterminada como
parcial (excepto quizás las muy simples). Luego puse las implementaciones predeterminadas en un archivo separado con una convención de nomenclatura de la forma
"ILogger.LogInfoDefaults.cs" ,
"ILogger.LogErrorDefaults.cs" y así sucesivamente. Si hay pocas implementaciones predeterminadas y no hay necesidad de agrupación adicional, entonces llamo al archivo
"ILogger.Defaults.cs" .
Esto separa a los miembros con implementación predeterminada del contrato no implementable, que las clases heredadas deben implementar. Además, le permite cortar archivos muy largos. También hay un truco complicado con la representación de archivos adjuntos de estilo
ASP.NET en proyectos de cualquier formato. Para hacer esto, agregue al archivo del proyecto o en
Directory.Build.props :
<ItemGroup> <ProjectCapability Include="DynamicDependentFile"/> <ProjectCapability Include="DynamicFileNesting"/> </ItemGroup>
Ahora puede seleccionar
"Anidamiento de archivos" en el
Explorador de soluciones y todos sus archivos
.Defaults.cs aparecerán como descendientes del archivo de interfaz "principal".
En conclusión, todavía hay varias situaciones en las que se prefieren los métodos de extensión:
- Si normalmente trabaja con clases, no con interfaces (porque tiene que convertir objetos en interfaces para acceder a las implementaciones predeterminadas)
- Si a menudo usa extensiones con plantillas: public static T SomeExt < T > (this T foo) (por ejemplo, en la API Fluent )