DEV Community

loading...
Cover image for Why interface default implementations in C# are a great thing

Why interface default implementations in C# are a great thing

Lolle2000la
・5 min read

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

Meme, in it is written "It's older code sir, but it checks out"

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 IWarningLoggerfor 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!

Discussion (14)

Collapse
tyrrrz profile image
Alexey Golub

So the old monolith code used to prepend "[error]" in its own so you've added new methods and you have to change the consuming code so that it uses them instead. If you have to update consuming code anyway, why not make a breaking change in the interface instead and do it properly?

Collapse
jessekphillips profile image
Jesse Phillips

The interface breaking implementation does not break consuming code.

What the default method brings is the ability for consuming code to use the new methods without the need to update the logging library, only the logging library contract. But this gets into layers of control and ownership.

Collapse
lolle2000la profile image
Lolle2000la Author

Because you don't want to break the existing consuming, and most importantly the implementing code.

Collapse
tyrrrz profile image
Alexey Golub

You didn't break it on paper, i.e. it's binary-compatible, but not semantically compatible, since you still have to update existing code to use the new API correctly.

Thread Thread
lolle2000la profile image
Lolle2000la Author • Edited

I agree, that's misunderstandable on my end. The point is that you can use the new API. Using it doesn't break the old implementation

Collapse
costinmanda profile image
Costin Manda

It seems to me that the both examples can be solved with extension methods. I don't like any of the solutions, though, and I may prefer yours. Probably DI solves everything with none of the issues of both.

What I dislike about IDIs is that interfaces will now come with extra dependencies for the code within. And having optional methods in an interface will bring the huge interfaces back from the hell they were thrown in, only easier to use.

Collapse
lolle2000la profile image
Lolle2000la Author • Edited

An extension method cannot save warnings to another database table for one implementation and to another file for another, etc.

That said, I think with time best practices will be known over time.

Collapse
costinmanda profile image
Costin Manda

Depending on what extension method class you are referencing, I guess. Never tried having different extension classes with the same signature, though. As I said, it would be ugly.

However, if you first refactor any codebase to use dependency injection, replacing one implementation with another becomes infinitely easy, not to mention testable, but it is a great step to make and many old code doesn't warrant that much effort.

My fear is that there will again be huge 500+ method interfaces and their reason d'etre will be "well, override only what you actually need". There is something inherently evil in interfaces with optional members, like a contract with a fine print, or one of those checkbox menus for marketing :)

Thread Thread
lolle2000la profile image
Lolle2000la Author

I agree, I have also thought about the possibility. I'm the beginning we had a similar problem with extension methods which had stuff like int.GetElementFromProductsTable() (of course a very hyperbolic example).

After people had understood how to use them correctly, extension methods became really useful and liked, but before many (and some still to this Date) habe believed they are cancer for C#.

I think default interface implementations will make a similar journey!

Collapse
jessekphillips profile image
Jesse Phillips

I think you'll need explain how interface updates wouldn't be involved in a dependency injection.

Not saying interfaces are required for DI but usually they are promoted with it.

Collapse
610yesnolovely profile image
Harvey Thompson • Edited

Default implementations for C# is a good compromise, as you say, for providing traits/multiple inheritance in a sensible manner.

There's some other nice features in C# 8.0 - I use a lot of different languages and rate C# pretty high up on the list of ones I prefer to use. Interesting to see C# 8.0 fixing nullability properly, but in a backwards compatible way - I think any language from now on should do this right, right from the start though.

I've not seen (though it may be a thing) Abstract Type Members in C# - a very useful feature, particularly with traits, something I miss on occasion in languages that don't have it.

Collapse
devestacion profile image
DevEstacion

I feel like this is something that's gonna be abused. Definitely a good feature to know but that is with proper knowledge of SOLID and other concepts.

Good write up btw

Collapse
lolle2000la profile image
Lolle2000la Author

I agree with you. This opens a few areas that could be abused, but I think it's worth it. We probablyshould not teach this to new programmers before SOLID.

Thank you for your answer! I appreciate it!

Collapse
hypeartist profile image
hypeartist

DII would be really great for using with struct's, but until there's no solution to avoid boxing it's kinda useless. Imo.