Lolle2000la
Posted on September 5, 2019
So recently I've read an article about how the C# 8.0 interfaces changes are bad. I think it's worth reading for yourself, but for the majority of this post it will not be necessary.
Let's first look at the central argument the article makes:
It's not a good idea for C# to implement traits using interfaces
Yes, I agree. It changes the meaning of interfaces. I also agree that multiple inheritance of abstract classes would've been better suited for implementing traits.
However, what I think you should really be excited about are all the other possibilities you have using default implementations:
Interoperability with Java and other languages
I think it's safe to say that mostly android developers profit from this. Interoperating with Java libraries can be a huge part of developing something for android, and Java has default implementations for interfaces. So having this feature in C# should help with language parity between both.
Keeping (assembly-level) backwards compatibility
A hypothetical scenario
Your company has a giant Monolith of code, seperated into 100+ libraries that are all dependant on one another.
Now, your team has been put in charge with adding a feature to one library, let's say (because this feels like a common example) logging of errors and warnings.
The current interface for ILogger
, which loggers like DatabaseLogger
or the plain ConsoleLogger
implement looks like this:
public interface ILogger
{
void Log(string message);
}
It has been a grave oversight to not add more methods for warnings and errors when there weren't a lot of implementations and consumers of that interface yet.
Currently everyone logs those differently (by prepending "[ERROR]" or "Error: " for example) and the company wants to have one convention for logging errors and warnings by providing the other teams with methods that log them correctly to the new convention.
Now pretty much every other library in the company depends on it to stay compatible (often on a assembly-level). A lot of them don't get updated often and the source code of some of them has been lost or was never owned by the company in the first place.
This means that new versions of the logging library must stay compatible with those old libraries, some of them contain core implementations. So all new library versions (and their produced assemblies) must be completely compatible, with no changes breaking the other libraries.
So now back to the problem. How do we add the features to the logging library?
If we just add new members to ILogger
, we break all existing implementations.
public interface ILogger
{
void Log(string message);
void LogWarning(string message); // this breaks all existing
void LogError(Exception ex); // implementations.
}
So
public class DatabaseLogger // this is legacy
{
void Log(string message)
{
// this is unknown
}
}
doesn't work anymore.
What can we do about it?
Currently there are a few things we can do about this, but none of them are really satisfying.
We could add new interfaces for error and warning loggers respectively for example. This would look like this:
public interface IWarningLogger
{
void LogWarning(string message);
}
public interface IErrorLogger
{
void LogError(Exception ex);
}
Now, all new and updated implementations could implement all three of them. There is a problem however. Let's assume in the constructor of every class the logger is passed via dependency injection. How can we pass a logger of all those types to a class?
One possibility is having a type parameter for the logger like this:
public class SomeConsumingClass<Logger>
where Logger : ILogger, IWarningLogger, IErrorLogger
{
public SomeConsumingClass(Logger logger) {}
}
Does this feel natural? Do you want to pollute every class with this? Does this even work with most Depenedency Injection frameworks? No? Then let's continue.
Another possibility is having seperate constructor parameters for every logger type, like the following:
public SomeConsumingClass(ILogger logger, IWarningLogger warningLogger, IErrorLogger errorLogger) {}
Eww, this hurts. This is so LONG. Imagine having another four parameters and then reading this a few months later.
One more thing springs to mind. You can check if ILogger
is a IErrorLogger
or IWarningLogger
for example.
if (logger is IErrorLogger errorLogger)
{
errorLogger.LogError(exception);
}
Now we can pass just one logger for all types to the class.
public SomeConsumingClass(ILogger logger)
{
if (logger is IErrorLogger errorLogger)
{
// example: errorLogger.LogError(exception);
}
}
Great. Now we only have one problem left. Either we do such a check everywhere we use IErrorLogger
or once in the constructor, saving it to the local state.
Both ways are ugly and waste space, and the checks on usage have a heavy computational weight, at least when the check was successful and a cast has to be done (even if you have a faster method for comparing the types).
So how can we do this better?
Enter default implementations
There are of course more ways to approach the subject, but default implementations are by far the easiest and best working ones.
We can add the members to the interfaces and give them implementations in C# 8.0 that will be used if the implementer doesn't explicitly implement them.
Basically it looks like this:
public interface ILogger
{
void Log(string message);
void LogWarning(string message) // this doesn't need to be implemented,
{ // and therefore does not break
Log($"[Warning] {message}"); // compatibility
}
void LogError(Exception ex) // same for this
{
Log($"[Error] {ex.Message}\n{ex.StackTrace}");
}
}
That's it. The new members are optional to implement and existing implementations will still work. Moreover you can now easily use LogWarning
and LogError
from a ILogger
, even if the implementer doesn't explicitly implement them.
So when you write:
logger.LogWarning("This is a warning!");
you can at least expect the entry to look like this:
...
[Warning] This is a warning
...
Great. Maybe the implementer decides to do something more specific, like logging errors on another table in the database or in a seperate file.
But if he isn't aware of those members yet, everything is still fine and dandy!
Partial implementations
The last example in fact shows another benefit. What if you just write your logs to a plain text file? Do you want to implement every one of these three methods, just with some other text prepended here and a exception message and stacktrace logged there?
Having default implementations means that in such an example, where it's plain text and nothing specific will be done to the text, except what's been described before, we don't need to implement all methods anymore. Just one that writes the messages into the file.
All other methods call this one method by default, so you can concentrate on writing to the file and not formatting the log entries correctly.👌
public class FileLogger : ILogger, IDisposable
{
private StreamWriter file; // handle for the file
public FileLogger(string fileName)
{
var fileStream = File.Open(fileName, FileMode.Append); // open the file
file = new StreamWriter(fileStream); // make it easy to write to
}
public void Log(string message) // this is the important part!!!!
{
file.WriteLine(message);
}
// Disposable pattern stuff omitted here
// LogWarning and LogError don't have to be implemented and use
// the default implementation.
}
var logger = new FileLogger("log.txt");
logger.LogWarning("This is a warning");
log.txt:
...
[Warning] This is a warning
...
Conclusion
Interface default implementations have a lot of great usages. It is not the best idea to realise traits through them, but as we have seen there are a lot of other things you can do with them.
I'm really looking forward to see them land in the next major release of C#, they are personally my biggest reason to be excited about C# 8.0.
Thank you for reading this to the end. To close this up I have a few questions for you.
What do you think of default implementations in C#? What other usages can you think about? Do you think they are a good or bad idea? I'm looking forward to your answers!
Posted on September 5, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 26, 2024