In
meinem letzten Beitrag habe ich versprochen, über einige Fälle zu sprechen, in denen es meiner Meinung nach sinnvoll ist, die Verwendung von Standardimplementierungen in Schnittstellen in Betracht zu ziehen. Diese Funktion hebt natürlich nicht viele bestehende Konventionen zum Schreiben von Code auf, aber ich habe festgestellt, dass die Verwendung der Standardimplementierung in einigen Situationen zu saubererem und besser lesbarem Code führt (zumindest meiner Meinung nach).
Erweitern von Schnittstellen mit Abwärtskompatibilität
Die Dokumentation sagt:
Das häufigste Szenario ist das sichere Hinzufügen von Methoden zu einer Schnittstelle, die bereits veröffentlicht und von unzähligen Clients verwendet wird.
Das zu lösende Problem besteht darin, dass jede von der Schnittstelle geerbte Klasse eine Implementierung für die neue Methode bereitstellen muss. Dies ist nicht sehr schwierig, wenn die Schnittstelle nur von Ihrem eigenen Code verwendet wird. Wenn sie sich jedoch in der öffentlichen Bibliothek befindet oder von anderen Befehlen verwendet wird, kann das Hinzufügen eines neuen Schnittstellenelements zu großen Kopfschmerzen führen.
Betrachten Sie ein Beispiel:
interface ICar { string Make { get; } } public class Avalon : ICar { public string Make => "Toyota"; }
Wenn ich dieser Schnittstelle eine neue
GetTopSpeed () -Methode hinzufügen möchte, muss ich ihre Implementierung in
Avalon hinzufügen:
interface ICar { string Make { get; } int GetTopSpeed(); } public class Avalon : ICar { public string Make => "Toyota"; public int GetTopSpeed() => 130; }
Wenn ich jedoch eine Standardimplementierung der
GetTopSpeed () -Methode in
ICar erstelle , muss ich sie nicht jeder geerbten Klasse hinzufügen.
interface ICar { string Make { get; } public int GetTopSpeed() => 150; } public class Avalon : ICar { public string Make => "Toyota"; }
Bei Bedarf kann ich die Implementierung in Klassen, für die der Standard nicht geeignet ist, immer noch überladen:
interface ICar { string Make { get; } public int GetTopSpeed() => 150; } public class Avalon : ICar { public string Make => "Toyota"; public int GetTopSpeed() => 130; }
Es ist wichtig zu berücksichtigen, dass die Standardmethode
GetTopSpeed () nur für Variablen verfügbar ist, die in
ICar umgewandelt wurden, und
Avalon nicht zur Verfügung steht, wenn sie nicht überladen ist. Dies bedeutet, dass diese Technik am nützlichsten ist, wenn Sie mit Schnittstellen arbeiten (andernfalls wird Ihr Code mit vielen Casts an Schnittstellen überflutet, um Zugriff auf die Standardimplementierung der Methode zu erhalten).
Mixins und Eigenschaften (oder so ähnlich)
Ähnliche Sprachkonzepte von
Mixins und
Merkmalen beschreiben Möglichkeiten, das Verhalten eines Objekts durch Komposition zu erweitern, ohne dass eine Mehrfachvererbung erforderlich ist.
Wikipedia berichtet über Mixins:
Mixin kann auch als Schnittstelle mit Standardmethoden betrachtet werden
Klingt das so?
Trotz der Standardimplementierung sind Schnittstellen in
C # keine Mixins. Der Unterschied besteht darin, dass sie auch Methoden ohne Implementierung enthalten können, die Vererbung von anderen Schnittstellen unterstützen, spezialisiert werden können
(anscheinend bezieht sich dies auf Vorlagenbeschränkungen. - ca. Transl.) Und so weiter. Wenn wir jedoch eine Schnittstelle erstellen, die nur Methoden mit einer Standardimplementierung enthält, handelt es sich tatsächlich um ein traditionelles Mixin.
Betrachten Sie den folgenden Code, der dem Objekt "Bewegung" Funktionen hinzufügt und dessen Position verfolgt (z. B. in Game Dev):
public interface IMovable { public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; }
Autsch! Es gibt ein Problem in diesem Code, das ich erst bemerkte, als ich anfing, diesen Beitrag zu schreiben und versuchte, ein Beispiel zu kompilieren. Schnittstellen (auch solche mit einer Standardimplementierung) können den Status nicht speichern. Daher unterstützen Schnittstellen keine automatischen Eigenschaften. Aus der
Dokumentation :
Schnittstellen können den Instanzstatus nicht speichern. Obwohl statische Felder jetzt in Schnittstellen zulässig sind, können Instanzfelder immer noch nicht verwendet werden. Daher können Sie keine automatischen Eigenschaften verwenden, da diese implizit versteckte Felder verwenden.
In diesem
C # stehen Schnittstellen im Widerspruch zum Konzept der Mixins (soweit ich sie verstehe, können Mixins den Status konzeptionell speichern), aber wir können immer noch das ursprüngliche Ziel erreichen:
public interface IMovable { public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; }
Auf diese Weise haben wir das erreicht, was wir wollten, indem wir die
Move () -Methode und ihre Implementierung allen Klassen zur Verfügung gestellt haben, die die
IMovable- Schnittstelle implementieren. Natürlich muss die Klasse noch eine Implementierung für die Eigenschaften bereitstellen, aber zumindest werden sie in der
IMovable- Schnittstelle
deklariert , wodurch die Standardimplementierung von
Move () mit ihnen arbeiten kann und sichergestellt wird, dass jede Klasse, die die Schnittstelle implementiert, den richtigen Status hat.
Betrachten Sie als vollständigeres und praktischeres Beispiel ein Mixin für die Protokollierung:
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}"); }
Jetzt kann ich in jeder Klasse von der
ILogger- Schnittstelle erben:
public class Foo : ILogger { public void DoSomething() { ((ILogger)this).LogInfo("Woot!"); } }
Und so ein Code:
Foo foo = new Foo(); foo.DoSomething();
Ausgänge:
[INFO] Foo: Woot!
Erweiterungsmethoden ersetzen
Die nützlichste Anwendung, die ich gefunden habe, ist das Ersetzen einer großen Anzahl von Erweiterungsmethoden. Kehren wir zu einem einfachen Protokollierungsbeispiel zurück:
public interface ILogger { void Log(string level, string message); }
Bevor Standardimplementierungen in Schnittstellen angezeigt wurden, schrieb ich in der Regel viele Erweiterungsmethoden für diese Schnittstelle, sodass in einer geerbten Klasse nur eine Methode implementiert werden sollte, wodurch Benutzer Zugriff auf viele Erweiterungen haben würden:
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}"); }
Dieser Ansatz funktioniert gut, aber nicht ohne Mängel. Beispielsweise stimmen Klassennamensräume mit Erweiterungen und Schnittstellen nicht unbedingt überein. Dazu störendes visuelles Rauschen in Form eines Parameters und eines Links zu einer Logger-Instanz:
this ILogger logger logger.Log
Jetzt kann ich die Erweiterungen durch Standardimplementierungen ersetzen:
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}"); }
Ich finde diese Implementierung sauberer und leichter zu lesen (und zu unterstützen).
Die Verwendung der Standardimplementierung bietet gegenüber Erweiterungsmethoden noch einige weitere Vorteile:
- Kann das nutzen
- Sie können nicht nur Methoden, sondern auch andere Elemente bereitstellen, z. B. Indexer
- Die Standardimplementierung kann überladen sein, um das Verhalten zu verdeutlichen.
Was mich im obigen Code verwirrt, ist, dass es nicht ganz offensichtlich ist, welche Schnittstellenmitglieder eine Standardimplementierung haben und welche Teil des Vertrags sind, den die geerbte Klasse implementieren soll. Ein Kommentar, der die beiden Blöcke trennt, mag helfen, aber ich mag die strikte Klarheit der Erweiterungsmethoden in dieser Hinsicht.
Um dieses Problem zu lösen, begann ich, Schnittstellen mit Mitgliedern mit der Standardimplementierung als
teilweise zu deklarieren (außer vielleicht sehr einfachen). Dann habe ich die Standardimplementierungen in eine separate Datei mit einer Namenskonvention der Form
"ILogger.LogInfoDefaults.cs" ,
"ILogger.LogErrorDefaults.cs" usw.
eingefügt . Wenn es nur wenige Standardimplementierungen gibt und keine zusätzliche Gruppierung erforderlich ist,
nenne ich die Datei
"ILogger.Defaults.cs" .
Dies trennt Mitglieder mit Standardimplementierung vom nicht implementierbaren Vertrag, für dessen Implementierung geerbte Klassen erforderlich sind. Darüber hinaus können Sie sehr lange Dateien schneiden. Es gibt auch einen kniffligen Trick beim Rendern von angehängten Dateien im
ASP.NET- Stil in Projekten eines beliebigen Formats. Fügen Sie dazu der Projektdatei oder in
Directory.Build.props Folgendes hinzu :
<ItemGroup> <ProjectCapability Include="DynamicDependentFile"/> <ProjectCapability Include="DynamicFileNesting"/> </ItemGroup>
Jetzt können Sie im
Projektmappen- Explorer "Dateiverschachtelung" auswählen und alle Ihre
.Defaults.cs- Dateien werden als Nachkommen der "Haupt" -Schnittstellendatei
angezeigt .
Zusammenfassend lässt sich sagen, dass es immer noch mehrere Situationen gibt, in denen Erweiterungsmethoden bevorzugt werden:
- Wenn Sie normalerweise mit Klassen arbeiten, nicht mit Schnittstellen (da Sie Objekte in Schnittstellen umwandeln müssen, um auf Standardimplementierungen zuzugreifen).
- Wenn Sie häufig Erweiterungen mit Vorlagen verwenden: public static T SomeExt < T > (dieses T foo) (z. B. in der Fluent-API )