csharplang/meetings/2017/LDM-2017-06-14.md
2017-06-19 17:08:34 -07:00

9.5 KiB

C# Language Design Notes for Jun 14, 2017

Agenda

Several issues related to default implementations of interface members

  1. Virtual properties with private accessors
  2. Requiring interfaces to have a most specific implementation of all members
  3. Member declaration syntax revisited
  4. Base calls

Virtual properties with private accessors

Classes (strangely) allow virtual properties with one accessor being private. Should interfaces allow something similar?

Conclusion

No. It is not obvious what it means, or whether it's in any way useful.

Requiring interfaces to have a most specific implementation of all members

Should it be an error for an interface to not have a single most specific implementation if each member (even inherited) that has default implementations?

Conclusion

This might be desirable if we were designing the language from scratch, but it would be breaking, since existing compilers wouldn't know to check that. Also, there'd be no way to fix it if they could.

So we cannot disallow this. The buck stops with the implementing class, which is the one that has to implement any interface member that doesn't have a unique most specific default implementation.

This does allow you to "reabstract" a default-implemented member by injecting a diamond situation and forcing a class level error, but so be it.

Member declaration syntax revisited

In a previous meeting we changed the syntax around "overriding" of default implementations to match that of explicit implementation in classes today.

This topic explores whether we have the right syntax when declaring an interface member and giving it a default implementation at the same time.

Today the syntax for this and an overriding implementation is simply:

interface I1
{
    int M(int i, bool b, string s) { return s?.Length ?? i; }
}

interface I2 : I1
{
    int I1.M(int i, bool b, string s) { return b ? i : s?.Length ?? 0; }
}

Or, if we use expression bodies:

interface I1
{
    int M(int i, bool b, string s) => s?.Length ?? i;
}

interface I2 : I1
{
    int I1.M(int i, bool b, string s) => b ? i : s?.Length ?? 0;
}

The declaration syntax in I1 has both pros and cons. One the one hand:

  • It is simply what you get if you take today's interface member declaration syntax and add a body
  • It looks similar to a class member that might implicitly implement an interface member

On the other:

  • It looks exactly like a non-virtual class member, which cannot be overridden/reimplemented by anything else
  • It's odd why the implementation in I2 needs to look like an explicit implementation (I1.M) when one that's immediately declared does not

One could argue that the current syntax should be reserved for the same meaning that it has in classes: a non-virtual member. But either way leads to confusion and surprise for some:

  • I "simply" add a body to an interface member, and all of a sudden it is no longer implementable by classes or derived interfaces!
  • I "simply" copy a nonvirtual member from a class to an interface, and all of a sudden it is re-implementable by classes and derived interfaces!

We need to decide which side of this conundrum to land on. If we were to change the meaning of

    int M(int i, bool b, string s) => s?.Length ?? i;

to be "nonvirtual" member what would an implementable interface member with a default implementation look like instead? Let's explore some options:

Proposal 0

Status quo. Non-virtual members are explicitly annotated with sealed, which isn't great, but we can live with it.

Proposal 1

Make a separate declaration that uses the same "explicit implementation" syntax as in the derived classes:

interface I1
{
    int M(int i, bool b, string s); // declares the member
 	int I1.M(int i, bool b, string s) => s?.Length ?? i; // provides a default implementation
}

interface I2 : I1
{
    int I1.M(int i, bool b, string s) => b ? i : s?.Length ?? 0; // no change
}

This is probably the most hideous and verbose option we can come up with. But points for consistency!

Proposal 2

Use a default keyword as a modifier to signal that a default implementation is provided:

interface I1
{
    default int M(int i, bool b, string s) => s?.Length ?? i;
}

default may or may not be allowed and/or required on a derived implementation as well:

interface I2 : I1
{
    default int I1.M(int i, bool b, string s) => b ? i : s?.Length ?? 0;
}

We don't hate this option, but it may be a bit too much syntax "just for the benefit of first timers".

It signals: "I am not really implementing it here; I'm providing a default!" That may allay the sense that interfaces are becoming too much like classes.

Proposal 3

Same as 2, but with the virtual keyword instead:

interface I1
{
    virtual int M(int i, bool b, string s) => s?.Length ?? i;
}

virtual may or may not be allowed and/or required on a derived implementation as well:

interface I2 : I1
{
    virtual int I1.M(int i, bool b, string s) => b ? i : s?.Length ?? 0;
}

This feels like we're going back towards virtual methods in classes in a way we don't want to. Also, it anything it should probably then be override and not virtual on the derived implementation, but now we're just going in circles.

Additional consideration

If we ever want to allow implementable static members in interfaces (that are required to be provided by implementing classes), then the syntactic choices we make here will potentially come back to bite us.

Conclusion

We don't like Option 3, and we hate Option 1. We're not totally averse to Option 2, but for now we'll keep the status quo, which seems to have the least syntax for the most common tasks.

Base calls

We pondered briefly whether we could live without the ability to call default implementations directly, just as you cannot call an explicit implementation today. However, we think that we do need to allow it. A particularly strong example is when you have ambiguous default implementations from different base interfaces. How do you pick one if you cannot call it?

Disambiguation

There are two ways in which a base call to a default implementation can be ambiguous:

  • Implementation ambiguity: For a given original declaration, there may be two base interfaces both inheriting that declaration and providing competing implementations for it. Which one did you want to call?
  • Declaration ambiguity: There may be two base interfaces that happen to declare members of the same signature. Which one did you mean?

We can think of disambiguating syntax for both:

  • base(I1).M: I want I1's implementation of M
  • base.(I1.M): I want my base's implementation of the M that was declared in I1
  • base(I3).(I1.M): I want I3's implementation of the M that was declared in I1

However we think that implementation ambiguity is going to be common, and declaration ambiguity is more esoteric. We are happy to add disambiguation syntax only for implementations for now.

Aside: Name disambiguation

There's an interesting and more general feature waiting for another time in the ability to disambiguate a member name by where it's declared, as in e.(I1.M). This could be used to get at hidden members or implemented interface members without casting, etc. But let's save that for another day.

Syntax

As for the implementation disambiguation, we have three proposals on the table:

base(T) // 1
base<T> // 2
T.base  // 3

We don't like 3 at all (even though that's essentially Java's syntax for it). We have some sympathy for 2, but it looks like there's generics involved, and there's not. We like 1 the best. It is reminiscent of other places in the language where a keyword is followed by a type in parentheses:

typeof(T)
default(T)
sizeof(T)

Semantics

When you access a base implementation with the syntax base(T), the meaning is "get the implementation of the member from T". This should at least work when T is an interface (but we are also interested in making it work for classes, letting you skip to an earlier base).

It is a compile time error if there is no default implementation of the member in T. If there is, that implementation is called directly. At runtime, if the implementation is no longer there, it is probably not worth it to try to walk the graph and find another one: we should just throw at this point.

These semantics mean that you can get in trouble if you rearrange your default implementations and don't recompile things that depend on you. So be it.

Unqualified base

base without a type should also be allowed for default implementations, as long as it is unambiguous. It means the same as base(T) where T is the interface that has the unique most specific default implementation at the time where the base access is compiled.

Conclusion

base and base(T) will be allowed to access default implementations in interfaces, both from derived interfaces and implementing classes. They are compiled to directly call the implementation in the interface they (implicitly or explicitly) designate.

It is worth pondering whether this is all too "static". It is not enlisting the runtime as much as perhaps it could, meaning that more specific default implementations could be ignored if they weren't known when the calling code was compiled. It is, however, equivalent to what we do with base calls in classes today. And it avoids a whole class of problems and error modes, because the compiler hardwires its choice of implementation into the code.