No
meu último post, prometi falar sobre alguns casos em que, penso, faz sentido considerar o uso de implementações padrão em interfaces. Esse recurso, é claro, não cancela muitas convenções existentes para escrever código, mas descobri que em algumas situações o uso da implementação padrão leva a um código mais limpo e mais legível (pelo menos na minha opinião).
Expandindo interfaces com compatibilidade com versões anteriores
A documentação diz:
O cenário mais comum é adicionar métodos com segurança a uma interface já publicada e usada por inúmeros clientes.
O problema a ser resolvido é que cada classe herdada da interface deve fornecer uma implementação para o novo método. Isso não é muito difícil quando a interface é usada apenas por seu próprio código, mas se estiver na biblioteca pública ou por outros comandos, a adição de um novo elemento de interface pode causar uma grande dor de cabeça.
Considere um exemplo:
interface ICar { string Make { get; } } public class Avalon : ICar { public string Make => "Toyota"; }
Se eu quiser adicionar um novo método
GetTopSpeed () a essa interface, preciso adicionar sua implementação no
Avalon :
interface ICar { string Make { get; } int GetTopSpeed(); } public class Avalon : ICar { public string Make => "Toyota"; public int GetTopSpeed() => 130; }
No entanto, se eu criar uma implementação padrão do método
GetTopSpeed () no
ICar , não precisarei adicioná-lo a cada classe herdada.
interface ICar { string Make { get; } public int GetTopSpeed() => 150; } public class Avalon : ICar { public string Make => "Toyota"; }
Se necessário, ainda posso sobrecarregar a implementação em classes para as quais o padrão não é adequado:
interface ICar { string Make { get; } public int GetTopSpeed() => 150; } public class Avalon : ICar { public string Make => "Toyota"; public int GetTopSpeed() => 130; }
É importante considerar que o método padrão
GetTopSpeed () estará disponível apenas para variáveis convertidas para
ICar e não estará disponível para
Avalon se não houver sobrecarga. Isso significa que essa técnica é mais útil se você estiver trabalhando com interfaces (caso contrário, seu código será inundado com muitas transmissões para interfaces para obter acesso à implementação padrão do método).
Mixins e características (ou algo parecido)
Conceitos de linguagem semelhantes de
mixins e
características descrevem maneiras de expandir o comportamento de um objeto através da composição sem a necessidade de herança múltipla.
A Wikipedia relata o seguinte sobre mixins:
Mixin também pode ser considerado como uma interface com métodos padrão
Isso soa assim?
Porém, mesmo com a implementação padrão, as interfaces em
C # não são mixins. A diferença é que eles também podem conter métodos sem implementação, suportar herança de outras interfaces, podem ser especializados
(aparentemente, isso se refere a restrições de modelo. - aprox. Transl.) E assim por diante. No entanto, se criarmos uma interface que contenha apenas métodos com uma implementação padrão, ela será, de fato, uma combinação tradicional.
Considere o código a seguir, que adiciona funcionalidade ao objeto "movimento" e acompanha sua localização (por exemplo, no game dev):
public interface IMovable { public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; }
Ai! Há um problema neste código que eu não percebi até começar a escrever esta postagem e tentar compilar um exemplo. As interfaces (mesmo aquelas que têm uma implementação padrão) não podem armazenar o estado. Portanto, as interfaces não suportam propriedades automáticas. A partir da
documentação :
As interfaces não podem armazenar o estado da instância. Embora agora os campos estáticos sejam permitidos nas interfaces, os campos da instância ainda não podem ser usados. Portanto, você não pode usar propriedades automáticas, pois elas implicitamente usam campos ocultos.
Neste
C # interfaces estão em desacordo com o conceito de mixins (tanto quanto eu os entendo, mixins conceitualmente podem armazenar estado), mas ainda podemos atingir o objetivo original:
public interface IMovable { public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; }
Assim, conseguimos o que queríamos, disponibilizando o método
Move () e sua implementação para todas as classes que implementam a interface
IMovable . Obviamente, a classe ainda precisa fornecer uma implementação para as propriedades, mas pelo menos elas são declaradas na interface
IMovable , o que permite que a implementação padrão do
Move () trabalhe com elas e garante que qualquer classe que implemente a interface tenha o estado correto.
Como um exemplo mais completo e prático, considere uma combinação para o log:
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}"); }
Agora, em qualquer classe, posso herdar da interface
ILogger :
public class Foo : ILogger { public void DoSomething() { ((ILogger)this).LogInfo("Woot!"); } }
E esse código:
Foo foo = new Foo(); foo.DoSomething();
Saídas:
[INFO] Foo: Woot!
Substituindo métodos de extensão
O aplicativo mais útil que encontrei está substituindo um grande número de métodos de extensão. Vamos voltar a um exemplo simples de log:
public interface ILogger { void Log(string level, string message); }
Antes das implementações padrão aparecerem nas interfaces, como regra, eu escrevia muitos métodos de extensão para essa interface, para que em uma classe herdada você precisasse implementar apenas um método, como resultado dos quais os usuários teriam acesso a muitas extensões:
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}"); }
Essa abordagem funciona muito bem, mas não sem falhas. Por exemplo, namespaces de classe com extensões e interfaces não necessariamente correspondem. Mais ruído visual irritante na forma de um parâmetro e um link para uma instância do logger:
this ILogger logger logger.Log
Agora eu posso substituir as extensões pelas implementações padrão:
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}"); }
Acho essa implementação mais limpa e fácil de ler (e dar suporte).
O uso da implementação padrão também possui várias outras vantagens sobre os métodos de extensão:
- Pode usar isso
- Você pode fornecer não apenas métodos, mas também outros elementos: por exemplo, indexadores
- A implementação padrão pode estar sobrecarregada para esclarecer o comportamento.
O que me confunde no código acima é que não é óbvio quais membros da interface têm uma implementação padrão e que fazem parte do contrato que a classe herdada deve implementar. Um comentário que separa os dois blocos pode ajudar, mas eu gosto da clareza estrita dos métodos de extensão a esse respeito.
Para resolver esse problema, comecei a declarar interfaces que possuem membros com a implementação padrão como
parciais (exceto talvez muito simples). Em seguida, coloquei as implementações padrão em um arquivo separado com uma convenção de nomenclatura no formato
“ILogger.LogInfoDefaults.cs” ,
“ILogger.LogErrorDefaults.cs” e assim por diante. Se houver poucas implementações padrão e não for necessário agrupamento adicional,
nomeio o arquivo
"ILogger.Defaults.cs" .
Isso separa os membros com implementação padrão do contrato não implementável, que as classes herdadas precisam implementar. Além disso, permite cortar arquivos muito longos. Também existe um truque para renderizar arquivos anexados no estilo
ASP.NET em projetos de qualquer formato. Para fazer isso, adicione ao arquivo do projeto ou em
Directory.Build.props :
<ItemGroup> <ProjectCapability Include="DynamicDependentFile"/> <ProjectCapability Include="DynamicFileNesting"/> </ItemGroup>
Agora você pode selecionar
"Aninhamento de arquivos" no
Solution Explorer e todos os seus arquivos
.Defaults.cs aparecerão como descendentes do arquivo de interface "principal".
Em conclusão, ainda existem várias situações nas quais os métodos de extensão são preferidos:
- Se você costuma trabalhar com classes, não com interfaces (porque você precisa converter objetos em interfaces para acessar implementações padrão)
- Se você costuma usar extensões com modelos: public static T SomeExt < T > (este T foo) (por exemplo, na Fluent API )