Saturday, February 4, 2012

Why Sealed Classes Should Be Allowed In Type Constraints

One of my older posts on Stackoverflow listed some of what I consider to be flaws of C# and/or the .NET runtime. A recent reply to my post posed a good question about one of those flaws, which was that sealed classes should be allowed as type constraints. That seems like a sensible restriction for C# at first, but there are legitimate programs that it disallows.

I figured others would have run into this problem at some point, but a quick Google search didn't turn up much, so I will document the actual problem with this rule. Consider the following interface:

interface IFoo<T>
{
    void Bar<U>(U bar) where U : T;
}

The important part to notice here is the type constraint on the method, U : T. This means whatever T we specify for IFoo<T>, we should be able to list as a type constraint on the method Bar. Of course, if T is a sealed class, we cannot do this:

class Foo : IFoo<string>
{
    public void Bar<U>(U bar)
      where U : string //ERROR: string is sealed!
    {
    }
}

In this case, there's a workaround by allowing the compiler to infer the constraint by making the method private and visible only when coerced as an interface:

class Foo : IFoo<string>
{
    void IFoo<string>.Bar<U>(U bar)
    {
    }
}

But this means that you cannot call Bar on a Foo, you need to first cast it to an IFoo<string>, a completely unnecessary step.

In principle, every type constraint that the compiler can infer implicitly, we should be able to specify explicitly. This is clearly not the case here, and there is no reason for it. It's a purely an aesthetic restriction, not a correctness restriction, that the C# compiler devs took extra effort to implement.

And that is why we should allow sealed classes as type constraints.

12 comments:

Unknown said...

Why can't you just have U=T in your example? (i.e. void Bar(T t) instead of void Bar<U>(U u) where U:T) What are you gaining by introducing U type parameter?

Sandro Magi said...

Having U gains you access to the concrete type U, instead of just the super type T. Perhaps that sounds tautological, but it's the whole point of generics, ie. propagating type information instead of losing type information by encapsulating it.

Loss of type information isn't always a problem obviously, but sometimes it is. Consider you have a static class:

public static class State<T>
{
public static T value;
}

Your Bar without U can no longer access a different state for each U via State<U>.value, it can only access State<T>.value.

This is again a trivial example, but the point is simply to illustrate that throwing away type information has real consequences. If the only reason to forbid sealed classes as type constraints are aesthetic and not for correctness, then that's not a good enough reason.

Ilia Jerebtsov said...

I'm not sure I understand. If T is a sealed class, how can there be any U that inherits T?

Sandro Magi said...

@Illia, assuming T = string, then U = string also satisfies the type constraint.

The point is that you don't know the types U and T when you define IFoo<T>. Any implementation conforming to that interface should be allowed without jumping through hoops, including using a sealed class for T.

Ilia Jerebtsov said...

Ok, that makes sense. But what practical implementation of Bar<U> where U : T could there be that would be also useful on the consuming side? You will already have to know the concrete type of U in order to make use of it, which renders the abstraction moot.

Sandro Magi said...

@Illia, on the consuming side, IFoo<string> can only call Bar<string>. The point is that this aesthetic rule forbids any sort of program that has this sort of structure.

I do a lot of very high-level abstract programming, so I've run into this limitation a few times. It's probably not common in typical programs. A general purpose programming language should support both simple and advanced uses though.

This is just another example of C# making aesthetic restrictions instead of sticking to correctness restrictions. Another example that is widely considered to be a mistake, is not being able to specify Delegate or Enum as type constraints, ie. where T : Delegate. The sealed class type constraint is the same type of problem, just not widely regarded as a problem.

Qwertie said...

Good example... so I guess this is merely a C# restriction, not a .NET restriction, explaining why the explicit interface implementation is allowed but the ordinary method is not.

The other day I ran into that bizarre restriction that you can't use "where D:Delegate". It's hard to imagine why they added that restriction when .NET itself supports the constraint.

Sandro Magi said...

@Qwertie, indeed. That's why I added ITypeConstraint<T> to Sasa. With the ilrewriter tool also provided by Sasa, I can specify the type constraints C# forbids, but the CLR allows.

kvb said...

Actually, I think that the Delegate, Enum, etc. limitations are reasonable, in that what you really want is a strict subtype constraint. Otherwise, you could call the method with T equal to System.Delegate itself, which is not a delegate type - this is likely to break the callee if it's not carefully written, and may be a nonsensical use of the method.

Sandro Magi said...

@kvb, if by strict subtype, you mean < instead of <=, I don't follow your reasoning. You suggest this is because you could call a method with T = System.Delegate, but how do you propose to create an instance of that abstract class that's not also a legitimate delegate type?

Just FYI, the CLR supports type constraints on Enum and Delegate, it's only C# that does not, so it's strictly a C# limitation. That's why my Sasa class library has an IL rewriter that adds these type constraints to compiled C# class libraries.

kvb said...

Indeed, I do mean that you want < instead of <= in these cases. As to your question, for starters null is a valid System.Delegate and System.Enum instance, and you can always upcast concrete values of subtypes to these types, but keep in mind that the constraint might be on an output parameter anyway (where an instance is created via reflection, for example).

Consider a more strongly typed version of Enum.GetValues; given a call to a hypothetical Enum.GetValuesOfType<Enum> method there's no reasonable behavior other than throwing an exception (which is of course what Enum.GetValues(typeof(Enum)) does today). However, a naive user is likely to expect all calls to the strongly typed version to succeed without throwing exceptions, and the error message ("Type provided must be an Enum") might be a bit mystifying since most people don't stop to consider whether System.Enum is itself an Enum.

Of course you're right that the CLR supports these types in constraints (or rather, doesn't explicitly disallow them), and there are plenty of cases where they are useful. I wouldn't be upset to see them added to the language (and in fact, in the System.Enum and System.ValueType cases, you can effectively create a strict subtype constraint by additionally constraining the type to be a value type). I just think that the C# team's decision to restrict them makes sense in the context of their goals (e.g. eliminating code with corner cases that are almost certainly broken).

By comparison, I don't see any real downsides to allowing sealed types in constraints.

Sandro Magi said...

I agree it'd be nice if we could be more precise with our subtyping, but I'm not hopeful. That isn't to say that constraints on special classes should be disallowed for the user's own good. You can violate memory safety in C# via compiler flag, so clearly it's not a matter of not trusting the developer enough, ie. constraints on Enum being "almost certainly incorrect".

Your other points involving casting and reflection already violate any type properties your code may have. I wouldn't disallow a useful typing property X simply because typing properties Y and Z were violated elsewhere in the language.

Finally, I don't think there's an expectation that typed code generates no runtime errors on the CLR, merely certain types of errors are ruled out. For instance, you can easily generate implicit errors from within static class constructors, and this is very, very surprising to developers. We just have to live with it; runtime errors on the CLR are simply too pervasive to simply ignore just because of alleged type safety.