Article QFR5 Casts and type parameters do not mix

Casts and type parameters do not mix

by
ericlippert
from Fabulous adventures in coding on (#QFR5)

Here's a question I'm asked occasionally:

void M<T>(T t) where T : Animal{ // This gives a compile-time error: if (t is Dog) ((Dog)t).Bark(); // But this does not: if (t is Dog) (t as Dog).Bark();}

What's going on here? Why is the cast operator rejected? The reason illustrates yet again that the cast operator is super weird.

Let me begin by again saying that if you are doing a run-time type check of something generic, you're probably doing something wrong. Generics should be actually generic, and work the same on any possible type. This code has a bad smell about it. But that's not what I want to talk about today.

To illustrate why the language disallows this cast, let's consider a few scenarios. Suppose we have three types derived from Animal: Dog, Wolf and Cat. Moreover, we have a user-defined conversion from Wolf to Dog. The various conversions imposed by the cast operator all have different semantics:

Dog dog = new Dog();Cat cat = new Cat();Wolf wolf = new Wolf();object obj = cat;Dog d1 = (Dog) dog; // identity conversion -- no opDog d2 = (Dog) wolf; // user-defined conversion -- call to static methodDog d3 = (Dog) cat; // compile-time errorDog d4 = (Dog) obj; // run-time error

These examples give us two justifications for producing an error when casting t to Dog. The first is: if T is Cat then we would expect a cast to Dog to be a compile-time failure. Since we have no reason to suppose that T is not Cat, similarly this should produce a compile-time error.

Note that this justification again illustrates that generics are not templates. In C++ you know at compile time whether the template is being instantiated with Cat or not; in C#, generics are required to type check for any possible type argument, not just for the particular type arguments provided.

The second justification is: if T is Wolf then we should expect that a cast of t to Dog is a call to a method, but if T is Dog then the conversion is a no-op. Again, generics are not templates; the code is only generated once and has to work for any instantiation. The cast cannot be generated as both a call to a static method and as a no-op.

Of course, if instead of taking a T the method took a dynamic, the code for the cast would be generated as a call to a method that starts up the compiler again at runtime and generates fresh code; if the argument were a Wolf then a call to the conversion method would be dynamically generated, and if the argument were a Cat then the program would crash with a type error. Again, the point of generic types is to avoid the danger and performance cost of doing the analysis at runtime.

Now, one could reasonably point out that in the actual code we started with, we've already done a run-time check on t, and so we know that t is not a Wolf or Cat at the point of the cast. This feature - computing additional type information on variables in different portions of the code - has been suggested a number of times for C#, but it is a surprisingly hard feature to get right in the general case. There are a lot of TOCTOU-like problems; you must be able to prove that the variable you type checked did not mutate to have a different run-time type between the time of the check and the time of its use. Since C# does not attempt to implement this feature, the fact that the cast is protected by a condition, and therefore must be an identity or reference conversion is not taken into consideration.

(Aside: There are of course cheaper ways to make this feature work; for example, a proposed feature for C# 7 is if(t is Dog d) d.Bark(); where the is may introduce a new variable and assign the reference. Or allow variable declarations to be expressions that produce the value of the declared variable as their value: if((Dog d = t as Dog) != null) d.Bark(); There are lots of ways to solve this problem by introducing new syntax, but this is a good subject for another day.)

So why then does the as version work? Because is and as do not pay any attention to user-defined conversions! The set of conversions supported by as is much smaller, and the compiler can generate the same code for any as conversion regardless of how T is instantiated.

Similarly, ((Dog)(object)t).Bark() would compile because this code tells the compiler to simply do a runtime type check similar to what as does; if t were Wolf or Cat then this would be a runtime error.

The moral of the story here is that once again we see that the cast operator tries to do too much. The cast operator can be both a representation-preserving and a representation-changing operation, and the compiler must know which it is at compile time in order to generate code. With generics there often is not enough information available at compile time to know that, and so many casts must be made illegal.


2852 b.gif?host=ericlippert.com&blog=67759120
External Content
Source RSS or Atom Feed
Feed Location http://ericlippert.com/feed
Feed Title Fabulous adventures in coding
Feed Link https://ericlippert.com/
Reply 0 comments