Dynamic in C# VII: Phantom Method Semantics

Dynamic in C# VII: Phantom Method Semantics

  • Comments 2

By now, my hope is that you all have a well-rounded view of dynamic. We started this series by introducing dynamic and talking about the basics of the feature, and have just finished talking about some of the feature's limitations with the intent that giving both the good and the bad will help us gain a firm understanding of the topic.

So what more is there to talk about?

The thing that's been occupying my thoughts lately is the semantics around the phantom method. Recall from our previous discussion of the phantom method that it is the method which the compiler binds to when there is a dynamically typed argument with a static receiver. What exactly happens when the compiler determines that it needs to bind to this method? What checks will the compiler do? What checks should it do?

Static or dynamic?

The first question we need to ask ourselves is a somewhat philosophical one. Should the compiler treat binding against the phantom method as a dynamic operation with some static parts? Or a static operation with some dynamic parts? A quick example will allow us to consider both views:

public class C
{
    static void Main()
    {
        C c = new C();
        dynamic d = 10;

        c.Foo(d, 10);
        d.Foo(10, 10);
    }

    public void Foo<T, S>(T x, S y) { }
}

The first call is a statically known receiver with a dynamically typed argument. The second is a dynamically typed receiver with a statically known argument.

A 'dynamic' phantom

Lets consider the former position first. When the compiler binds the call to Foo against a dynamic receiver, it has no knowledge of the receiver's members. It therefore does not check for the existance of a Foo member, and does not perform checks like accessibility, arity, argument convertibility, or method type inference.

If the compiler were to consider binding against the phantom method as a dynamic operation with some static parts, then it should treat the first call in the same manner. This means that once the compiler encounters any method call with a dynamic argument, it should stop checking these things against the receiver, even though it knows the receiver's type at compile time and can therefore determine these things.

Seems counterintuitive doesn't it? If the compiler has the information, why wouldn't it use it to help give the user good diagnostic information at compile time? Ok, ok, I'm clearly biased and think that this is the wrong approach. :) Lets consider the second approach then.

A 'static' phantom

This position argues that the compiler use whatever static information it knows to help give diagnostics to the user wherever it can.

This means that it should perform name lookup on the receiver to make sure there is a method named Foo on class C.

It should check that the method Foo is accessible from the current location.

It should do an arity check to make sure that there is a Foo that takes two arguments.

It should do method type inference to determine as much information about the type parameters as it can. In our example, this means that the compiler will infer S to be type int, but not be able to infer T.

It should check that any non-dynamic arguments are convertible to their respective parameter types. In our example, this means verifying that the second argument is convertible to type int, since we inferred S to be int.

It should check the constraints of the method type parameters against the argument types.

Use the static information!

Though there may be some dynamic language guys that think we should be in the first camp, I'm of the opinion that C# is a static language, so lets stay in the latter camp and use as much static information as we can.

Moreover, we've already decided that for the method call off of a dynamic receiver, the compiler will encode the static types of the arguments so that the static types and not the runtime types will be used for overload resolution.

If we look on our little checklist above, all the items seem pretty straightforward - all but one (in my opinion). Method type inference. How should the type inference algorithm be altered to infer the most that it can, giving errors where it can guarantee that the code will never succeed, no matter what the runtime arguments?

Currently in our working compiler, we simply ignore type inference. That is, we skip type inference, and upon encountering a type parameter, we assume it is convertible at compile time. This can produce some unexpected behavior!

Consider the following example:

public class C
{
    static void Main()
    {
        C c = new C();
        dynamic d = 10;

        c.Foo(10, d);
    }

    public void Foo<T>(T t, int x) where T : class { }
}

One would expect that this produces a compile time error - the integer 10 given as the first argument will never satisfy the constraints to the type parameter T. However, we currently allow this call to compile successfully and fail at runtime!

Quite unexpected, yes?

Well, turns out we need to figure out a good way to have the type inference algorithm behave when it sees dynamically typed arguments in order for this to work.

A modification to the type inference algorithm

Here's where things get fun. Our current type inference algorithm has two results: pass or fail. We now need to introduce a third result: inconclusive.

Because I am the strongest believer that the current behavior is unacceptable, and that we need to make this change to type inference, it fell on me to come up with a reasonable proposal for the design team, and to see if I can usher it through.

So here goes!

I propose that we add the following behavior to the type inference algorithm. If the type of the argument is dynamic, then take all type parameters in the corresponding parameter's constructed type and mark them as inconclusive. No errors will be reported on inconclusive type parameters, and no constraint checks will be performed on them in the constraint checking phase.

Note that this proposal says nothing about types constructed over dynamic. For example, if we supplied an argument of type List<dynamic> to a parameter expecting an IEnumerable<T> where T : struct, then we would not mark T as inconclusive, and therefore report the error that the constraint is not satisfied.

A complex scenario

Lets consider a slightly more complex scenario.

public interface IAnimal { }
public interface IWatcher<in T> { }
public class Watcher<T> : IWatcher<T> { }

public class C
{
    static void Main(string[] args)
    {
        C c = new C();

        IWatcher<Giraffe> a = new Watcher<Giraffe>();
        IWatcher<Monkey> b = new Watcher<Monkey>();
        dynamic d1 = 10;
        dynamic d2 = new Watcher<Mammal>();
        IWatcher<dynamic> d3 = new Watcher<dynamic>();
        c.Bar(a, b, d1); // (1)
        c.Bar(a, b, d2); // (2)
        c.Bar(a, b, d3); // (3)
    }

    public void Bar<T>(IWatcher<T> w1, IWatcher<T> w3, IWatcher<T> w2) where T : IAnimal { }
}

public class Mammal : IAnimal { }
public class Giraffe : Mammal { }
public class Monkey : Mammal { }

In this example, the first two examples contain an argument that is typed dynamic. However, notice that we cannot simply ignore the dynamic argument and ignore the third parameter for method type inference.

If we were to do that, the type inference algorithm would first determine that the candidate set for T is {Giraffe, Monkey}. However, even though there is a common base class (ie Mammal), C#'s type inference algorithm requires that the base class be in the candidate set in order for a successful inference. Type inference would therefore fail at compile time.

In the first call, this is all fine and good - runtime type inference would also fail on the call. However, the second call will succeed at runtime! Because the runtime type of d2 is Watcher<Mammal>, Mammal is added to the candidate set. And because IWatcher is covariant on T, choosing T to be Mammal satisfies argument convertibility for each of the three arguments.

The third call will fail at compile time, because the candidate set for T is {Giraffe, Monkey, dynamic}, and T is not marked inconclusive. Type inference will infer T to be dynamic, since it is the common base class and IWatcher is covariant. However, constraint checking will fail, since dynamic is not an IAnimal.

Questions? Thoughts? Pick it apart!

This being my first real attempt at directly changing the spec and not just being a voice on the voting party, I'd greatly appreciate your thoughts and comments! Are there flaws in my proposed argument? Are there refinements that can help make the algorithm more robust? Are there clear scenarios that break down (ie we report an error at compile time, but the call can succeed at runtime)? Please send me your thoughts!

And as always, happy coding! And have a Merry Christmas!

Leave a Comment
  • Please add 1 and 4 and type the answer here:
  • Post
  • Welcome to the 48th Community Convergence. The C# team continues to work hard to get out the next version

  • Your complex scenario example is missing the where constraints on C.Bar<T>(...)'s signature. It's cut off at "wher". Line too long rendering issue?

Page 1 of 1 (2 items)