|<<>>|193 of 274 Show listMobile Mode

Sealed classes and methods

Published by marco on

Updated by marco on

According to the official documentation, the sealed keyword in C# serves the following dual purpose:

“When applied to a class, the sealed modifier prevents other classes from inheriting from it. […] You can also use the sealed modifier on a method or property that overrides a virtual method or property in a base class. This enables you to allow classes to derive from your class and prevent them from overriding specific virtual methods or properties.”

Each inheritable class and overridable method in an API is part of the surface of that API. Functionality on the surface of the API costs money and time because it implies a promise to support that API through subsequent versions. The provider of the API more-or-less guarantees that potential modifications—through inheritance or overriding—will not be irrevocably broken by upgrades. At the very least, it implies that so-called breaking changes are well-documented in a release and that an upgrade path is made available.

In C#, the default setting for classes and methods is that classes are not sealed and methods are sealed (non-virtual, which amounts to the same thing). Additionally, the default visibility in C# is internal, which means that the class or method is only visible to other classes in the assembly. Thus, the default external API for an assembly is empty. The default internal API allows inheritance everywhere.

Some designers recommend the somewhat radical approach of declaring all classes sealed and leaving methods as non-virtual by default. That is, they recommend reducing the surface area of the API to only that which is made available by the implementation itself. The designer should then carefully decide which classes should be extensible—even within the assembly, because designers have to support any API that they expose, even if it’s only internal to the assembly—and unseal them, while deciding which methods should be virtual.

From the calling side of the equation, sealed classes are a pain in the ass. The framework designer, in his ineffable wisdom, usually fails to provide an implementation that does just what the caller needs. With inheritance and virtual methods, the caller may be able to get the desired functionality without rewriting everything from scratch. If the class is sealed, the caller has no recourse but to pull out Reflector™ and make a copy of the code, adjusting the copy until it works as desired.

Until the next upgrade, when the original version gets a few bug fixes or changes the copied version begins to diverge from it. It’s not so clear-cut whether to seal classes or not, but the answer is—as with so many other things—likely a well-thought out balance of both approaches.

Sealing methods, on the other hand, is simply a way of reverting that method back to the default state of being non-virtual. It can be quite useful, as I discovered in a recent case, shown below.

I started with a class for which I wanted to customize the textual representation—a common task.

class Expression
{
  public override string ToString()
  {
    // Output the expression in human-readable form
  }
}

class FancyExpression : Expression
{
  public override string ToString()
  {
    // Output the expression in human-readable form
  }
}

So far, so good; extremely straightforward. Imagine dozens of other expression types, each overriding ToString() and producing custom output.

Time passes and it turns out that the formatting for expressions should be customizable based on the situation. The most obvious solution it to declare an overloaded version of ToString() and then call the new overload from the overload inherited from the library, like this:

class Expression
{
  public override string ToString()
  {
    return string ToString(ExpressionFormatOptions.Compact);
  }

  public virtual string ToString(ExpressionFormatOptions options)
  {
    // Output the expression in human-readable form
  }
}

Since the new overload is a more powerful version of the basic ToString(), we just redefine the latter in terms of the former, choosing appropriate default options. That seems simple enough, but now the API has changed and in a seemingly unenforcable way. Enforcable, in this context, means that the API can use the semantics of the language to force callers to use it in a certain way. Using the API in non-approved ways should result in a compilation error.

This new version of the API now has two virtual methods, but the overload of ToString() without a parameter is actually completely defined in terms of the second overload. Not only is there no longer any reason to override it, but it would be wrong to do so—because the API calls for descendants to override the more powerful overload and to be aware of and handle the new formatting options.

But, this is the second version of the API and there are already dozens of descendants that override the basic ToString() method. There might even be descendants in other application code that isn’t even being compiled at this time. The simplest solution is to make the basic ToString() method non-virtual and be done with it. Descendents that overrode that method would no longer compile; maintainers could look at the new class declaration—or the example-rich release notes!—to figure out what changed since the last version and how best to return to a compilable state.

But ToString() comes from the object class and is part of the .NET system. This is where the sealed keyword comes in handy. Just seal the basic method to prevent overrides and the compiler will take care of the rest.

class Expression
{
  public override sealed string ToString()
  {
    return ToString(ExpressionFormatOptions.Compact);
  }

  public virtual string ToString(ExpressionFormatOptions options)
  {
    // Output the expression in human-readable form
  }
}

Even without release notes, a competent programmer should be able to figure out what to do. A final tip, though, is to add documentation so that everything’s crystal clear.

class Expression
{
  /// <summary>
  /// Returns a text representation of this expression.
  /// </summary>
  /// <returns>
  /// A text representation of this expression.
  /// </returns>
  /// <remarks>
  /// This method can no longer be overridden; instead, 
  /// override <see cref="ToString(ExpressionFormatOptions)"/>.
  /// </remarks>
  /// <seealso cref="ToString(ExpressionFormatOptions)"/>
  public override sealed string ToString()
  {
    return ToString(ExpressionFormatOptions.Compact);
  }

  /// <summary>
  /// Gets a text representation of this expression using the given 
  /// <paramref name="options"/>.
  /// </summary>
  /// <param name="options">The options to apply.</param>
  /// <returns>
  /// A text representation of this expression using the given 
  /// <paramref name="options"/>
  /// </returns>
  public virtual string ToString(ExpressionFormatOptions options)
  {
    // Output the expression in human-readable form
  }
}