Telerik blogs
Industry NewsT2 Dark_1200x303

See how the evolution of a language forces change in its comprehension and usage, and gain an understanding of a new C# feature in the works: static abstract members in interfaces.

The concept and purpose of an interface in C# is historically understood. However, new C# features, both delivered and in-flight, are forcing developers to rethink what an interface is and reevaluate existing assumptions. In this article, I’ll discuss what an interface is, what these new features are and where interfaces (and the type system in general) may go in the future.

Introduction

Programming languages are essential to software development. They allow a developer to express their intent in a way that is easier than writing applications using lower-level constructs like assembly (or even machine) code. It’s desirable for a developer to understand how features in their programming language of choice work. That knowledge allows then to write efficient, scalable and maintainable applications.

Inevitably, programming languages evolve over time. New features may be added to make tasks easier or reduce the amount of code needed in certain situations. For example, in C# (a language I have used for over 20 years), Listing 1 shows how the canonical “hello world” was done for many years:

Listing 1: Traditional “Hello World” in C#

using System;

public static class Program
{
  public static void Main(string[] args)
  {
      Console.WriteLine("Hello world!");   
  }
}

Over the years, features like top-level statements and global using statements allow a developer to reduce the implementation to one line of code as demonstrated in Listing 2:

Listing 2: Modern “Hello World” in C#

Console.WriteLine("Hello world!");

There’s a tension between a language being stable and changing over time. Changes may bring benefits but they can also lead to confusion, especially if those changes affect a core concept that has been in place for a long time. Sometimes there may even be breaking changes, where a feature is no longer supported, or it works in a way that will cause compilation failures. Traditionally, programming languages tend to avoid breaking changes, as they can make it difficult for code written for an earlier version to be upgraded to the latest version. But, even if a new feature does not break code, it can lead to misunderstandings and uncertainty.

In this article, I’ll talk about interfaces in C#. I’ll start by explaining what they are and how they’re defined. Next, I’ll demonstrate how they changed in C# 8 with default interface members. Finally, I’ll show how the new feature static abstract members in interfaces (which is coming in C# 11) can potentially change how developers define and interact with interfaces.

Explaining Features in Interfaces

Let’s start our discussion on interfaces with the core features that have been in place since the first version of C#.

How Interfaces Typically Work

Interfaces are primarily used to define a contract, specifying what should be done but not how that should be accomplished. For example, Listing 3 defines an interface called IDriver, with one method, Drive().

Listing 3: Defining the IDriver Interface

public interface IDriver
{
  void Drive();
}

A class or struct will implement the interface however they choose to. Listing 4 shows two different implementations of IDriver, one for a golfer and one for a race car driver:

Listing 4: Implementing the IDriver Interface

public sealed class Golfer
  : IDriver
{
  public void Drive()
  {
    var value = RandomNumberGenerator.GetInt32(250, 320);
    Console.WriteLine($"{value} yards");
  }
}

public sealed class Racer
  : IDriver
{
  public void Drive()
  {
    var value = RandomNumberGenerator.GetInt32(120, 220);
    Console.WriteLine($"{value} MPH");
  }
}

As you can see, the implementations between these two are different. “Driving” for a golfer (which is measured in yards) does something that has no relation to what a race car driver would do (which is measured in MPH). However, both implement the same interface, so they’re used in the same way. For example, Listing 5 shows a method that takes a list of objects that implement IDriver and tell them all to drive:

Listing 5: Driving IDriver-Based Objects

public static void Drive(IEnumerable<IDriver> drivers)
{
  foreach(var driver in drivers)
  {
    driver.Drive();
  }
}

Drive(new IDriver[] { new Golfer(), new Racer() });

If you would run this code, you’d see something like this in the console window:

Drive 268 yards, 215 mph

How each object “drives” is irrelevant to the implementation of this method. This code only cares about the objects implementing the IDriver contract.

There are other details related to an interface, such as explicit interface implementation, but the key takeaway is that interfaces have always been about defining the contract between a user and an implementor. Interface members did not have implementations.

Providing Default Implementations

In C# 8, the interface landscape changed significantly. One modification was that member implementation could take place in an interface. This feature is called default interface members, or DIMs. The primary motivator for DIMs is that interfaces can be updated without breaking the classes that implement it. For example, in Listing 6, the IDriver interface is updated to have a Stop() method:

Listing 6: Updating an Interface

public interface IDriver
{
  void Drive();

  void Stop();
}

If a class implements IDriver, it now must implement both Drive() and Stop(). This forces developers to figure out what a Stop() implementation would do in their application. This isn’t optional for a developer. They must add implementation for Stop(). You cannot have a class that does not implement abstract members.

However, with a DIM, you can version the interface and not break existing implementations. This is shown in Listing 7:

Listing 7: Adding an Implementation to an Interface Member

public interface IDriver
{
  void Drive();

  void Stop() { }
}

In this case, Stop() will do nothing except allow existing IDriver implementations to work. That is, a developer does not have to update every class definition with a Stop() implementation when they get the new version of IDriver.

Another change to interfaces was allowing static members to be defined on the interface. We could take that method from the previous section that enumerates IDriver objects and put that into the IDriver interface itself as a static member, as shown in Listing 8:

Listing 8: Adding Static Members to an Interface

public interface IDriver
{
  public static void Drive(IEnumerable<IDriver> drivers)
  {
    foreach(var driver in drivers)
    {
      driver.Drive();
    }
  }
    
  void Drive();

  void Stop() { }
}

While I personally have a positive response to these changes, they caused a bit of a stir for some C# developers, especially those who had used the language for a long time. From a purist perspective, interface members do not have implementations, nor should they have static members, as their members were always implemented for instances of a type. While there may have been rational arguments for the addition of these features from the C# team, for some folks it felt like a violation on what an interface should (or should not) support. At the end of the day, whether a developer enjoyed the interface updates, it was clear that core aspects of the language were subject to modification.

Defining Static Abstract Members (Including Operators)

C# 11 is currently in preview mode, with its release slated for November 2022. One of the many features coming in this version is something called static abstract members in interfaces. This lets a developer define a static member in an interface that must be implemented in a class.

Note: If you want to try static abstract members in interfaces out, add the following property values to your C# project file:

  • <EnablePreviewFeatures>true</EnablePreviewFeatures>
  • <LangVersion>preview</LangVersion>

For example, if we go back to the IDriver scenario, let’s create a whole new interface: IModernDriver. This is shown in Listing 9:

Listing 9: Adding a Static Abstract Member to IDriver

public interface IModernDriver<TSelf>
  where TSelf : IModernDriver<TSelf>
{
  static abstract void Drive(IEnumerable<TSelf> modernDrivers);

  void Drive();
  
  void Stop() { }
}

Note that Drive() is static and abstract. This means that the class that implements it must have an implementation for this method, as demonstrated in Listing 10:

Listing 10: Providing an Implementation for a Static Abstract Member

public class ModernGolfer
  : IModernDriver<ModernGolfer>
{
  public static void Drive(IEnumerable<ModernGolfer> modernGolfers)
  {
    foreach(var modernGolfer in modernGolfers)
    {
      modernGolfer.Drive();
    }
  }

  public void Drive()
  {
    var value = RandomNumberGenerator.GetInt32(100, 150);
    Console.WriteLine($"Club head speed: {value} MPH");    
  }

  public void Stop()
  {
    var value = RandomNumberGenerator.GetInt32(250, 320);
    Console.WriteLine($"{value} yards");    
  }
}

The generic constraint on TSelf makes the type that the enumerable will iterate over the same as the type that implements the interface.

You can define properties to be static and abstract, and you can also define operators on an interface as well if want. This allows C# to handle primitive data types such as int and double in a generic way. I highly recommend reading this article for more information on using operators in interfaces.

Assumptions and Ramifications

As the previous section has shown, interfaces have gained substantial capabilities in recent years. They make it easier to handle versioning as well as provide techniques to use types in entirely new ways. However, they may also challenge developers in interesting ways.

For example, in C# it’s possible to call the implementation of a member in a base class. This is demonstrated in Listing 11:

Listing 11: Calling a Base Member

public abstract class Driver
{
  protected Driver() { }

  public abstract void Drive();

  public virtual void Stop() { }
}

public sealed class GolferDriver
  : Driver
{
  public override void Drive()
  {
    var value = RandomNumberGenerator.GetInt32(100, 150);
    Console.WriteLine($"Club head speed: {value} MPH");
  }

  public override void Stop()
  {
    base.Stop();

    var value = RandomNumberGenerator.GetInt32(250, 320);
    Console.WriteLine($"{value} yards");
  }
}

Now our base type Driver is no longer an interface. Instead, it’s an abstract class. Classes that derive from Driver still have to provide an implementation for Drive(), but Stop() already has an implementation. However, the Golfer class can decide to call the base implementation if it chooses to.

It may seem reasonable that there would be a way to do this with DIMs. In fact, according to the “Base interface invocations” section of the documentation, a developer might think they would be able to call Stop() on the IDriver interface as shown in Listing 12:

Listing 12: Trying to Call a DIM

public sealed class Golfer
  : IDriver
{
  public void Drive()
  {
    var value = RandomNumberGenerator.GetInt32(250, 320);
    Console.WriteLine($"{value} yards");
  }

  public override void Stop()
  {
    // Note: This doesn't work.
    IDriver.base.Stop();

    var value = RandomNumberGenerator.GetInt32(250, 320);
    Console.WriteLine($"{value} yards");
  }
}

Unfortunately, that does not work. This feature was a proposal but has not been implemented as of C# 10, and it’s unclear if it will be added to the language in the future. This lost me some time on a personal OSS project I have been working on called Rocks. I was trying to figure out if there was a way to call a base implementation from an interface, and when I read the documentation linked above, I thought it would be straightforward to add. Since this feature is not in C#, I had to try to find another way. Fortunately, it turned out to be possible, but it’s somewhat convoluted. Read this GitHub issue to see what it took to make it work.

Another example is code generators. Let’s say you built a tool that generates a class based on the definition of an interface. There’s one in Visual Studio (VS) that already does this, as shown in Figure 1:

Figure 1: Implementing an Interface Using a Visual Studio Refactoring Tool

Shows note: 'Golfer does not implement interface member 'IDriver.Drive()'. Code shows ...public void Drive()...

For C# 11, tools like this must consider that the members of an interface may be static and must have an implementation. Fortunately, as you can see in the screenshot in Figure 2, the preview version of VS 2022 has already updated the Implement Interface refactoring to handle this new feature:

Figure 2: Updated Visual Studio Refactoring Tool for Static Abstract Members

Code now has 'static' in it: ...public static void Drive()...

If you have tools that peruse the structure of an interface, you may need to revisit how they work. Are you assuming that every member of an interface will be an instance member? Are you looking for static members? If not, when you move to C# 11, these tools may generate invalid code that won’t compile. Take time to review existing tools and their behaviors so you’ll be ready when they’re run against modern C# code bases.

Conclusion

Change is inevitable in life, even in programming languages. A software developer must stay abreast of a language’s evolution. These additions can make some development scenarios easier to do and cause unexpected behaviors based on well-grounded assumptions. When a new version comes along with a language you use, I highly recommend that you review any associated changes with that new version, even if you don’t intend to upgrade in the immediate future.

I’m also a believer in looking at the roadmap of a language to see what might be coming. For example, there’s a long-running discussion on an idea called “Roles, extension interfaces and static interface members” for C#. This would have a substantial impact on a developer’s understanding of the type system—in fact, the third part, static interface members, have already been delivered. I encourage you to peruse the content on the C# Discussions page, as you never know when a feature will work its way into the language!

The code in this article can be found in the following repository: https://github.com/JasonBock/ChallengingAssumptionsWithLanguageFeatures.


C#
About the Author

Jason Bock

Jason Bock is a developer advocate at Rocket Mortgage and a Microsoft MVP (C#). He has over 25 years of experience working on several business applications using a diverse set of frameworks and languages. He is the author of “.NET Development Using the Compiler API,” “Metaprogramming in .NET” and “Applied .NET Attributes.” He has written numerous articles on software development issues and has presented at a number of conferences and user groups. He is a leader of the Twin Cities Code Camp. Jason holds a master’s degree in electrical engineering from Marquette University. Feel free to visit his website.

Related Posts

Comments

Comments are disabled in preview mode.