dotnet / roslyn

The Roslyn .NET compiler provides C# and Visual Basic languages with rich code analysis APIs.
https://docs.microsoft.com/dotnet/csharp/roslyn-sdk/
MIT License
19.05k stars 4.03k forks source link

Proposal: Extension Implementations #8127

Closed alrz closed 7 years ago

alrz commented 8 years ago

Consider this two interfaces.

interface Iterator { }

interface Iterable { 
  Iterator GetIterator();
}

So every class that implements Iterator is effectively an Iterable. To establish this relation, one might think of default methods:

interface Iterable { 
    default Iterator GetIterator() {
        return this; // ERROR
    }
}

But this doesn't help because you can't say if the implementor is an Iterator. We might define the default method as generic constrained to an Iterator.

interface Iterable { 
    default<T> Iterator GetIterator() where T : Iterator {
        return this; 
    }
}

But this design is somehow flawed (you might want more default generic implementations of the same method with different constraints) and limiting (you don't always have access to Iterable declaration).

So you decide to declare an extension method for every Iterator.

static class IteratorExtensions {
    public Iterator GetIterator(this Iterator @this) {
        return @this;
    }
}

And now you didn't establish any relation to Iterable interface at all. You know that it does have a method named GetIterator (duck typing) but the compiler doesn't.

So, here's my solution: define an implementation for every type that is an Iterator.

implement Iterable for Iterator {
    Iterator GetIterator() {
        return this;
    }
}

Q: OK, What if we have other constraints as well? A: then you might define it as a generic implementation,

implement<T> Iterable for T where T : Iterator {
    Iterator GetIterator() {
        return this;
    }
}

This is basically Rust's syntax for implementations but as an alternative you might suggest,

extension Iterator : Iterable {
    Iterator GetIterator() {
        return this;
    }
}

extension<T> T : Iterable where T : Iterator {
    Iterator GetIterator() {
        return this;
    }
}

which is closer to Swift's extensions.

And how this would work? Via virtual extension methods (#258). The compiler generates a static class for extension methods on the target type (or on the constrained generic type).

// the former case
static class _CompilerGeneratedName_ {
    public static Iterator GetIterator(this Iterator @this) {
        return @this;
    }
}

// the latter case
static class _CompilerGeneratedName_ {
    public static Iterator GetIterator<T>(this T @this) where T : Iterator {
        return @this;
    }
}

Note that it is possible to add members without any interface in question, e.g.

extension C {
  void ExtensionMethodForC() { }
}
extension<T> T /* where T */ {
  void ExtensionMethodForGenericT() { }
}

It would be nice to be able to name the generated static class, then it's possible to refactor existing classes like Enumerable (using an analyzer, perhaps) in this manner without breaking existing code, otherwise, extension methods would be accessible via the target type itself, e.g. C.ExtensionMethodForC.

This can neatly cover the #7844 use case. Suppose we have two classes with no relation to each other.

class A { public void M() { } }
class B { public void M() { } }

interface IHasM { void M(); }

extension A : IHasM { 
  // can be omitted due to the trivial implementation
  // void M() { this.M(); }
}

extension B : IHasM { }

As discussed in the aforementioned issue, this kind of duck typing can be inferred by the compiler but it cannot be done without a high risk of breaking existing code.

There’s an important restriction for implementations, either the interface or the type you’re implementing it for must be defined in the same assembly.

PS: (1) There are numerous open proposals regarding template/structural types, extension classes, etc, but none of them are directly related to #258 so I decided to open another one. (2) This is not about traits and zero-cost abstraction, but same syntax can be used for implementing traits for arbitrary types.

HaloFour commented 8 years ago

I'm not sure that I see how this works. #258 relies on the concrete class actually implementing the interface so the CLR will expect the slots for the implementing methods which it then wires up to the default implementations in the absence of an override provided by the class. Without that there are no slots to wire up or to call. The instance of A still can't be cast to an IHasA.

alrz commented 8 years ago

@HaloFour Yes, to cast an object to an interface which we provided the implementation afterwards the vtable shall be handled otherwise. I think this is something that is required to implement traits. But you can freely use it with generic constraints e.g.

void F<T>(T obj) where T : IHasM { }

F(new A());

Then we can dispatch the right method from constrained T.

HaloFour commented 8 years ago

I'm not sure I see how you can "handle" slots in a vtable that don't exist. And being able to state that any given type now is a given interface seems quite messy and could have wide ramifications on existing code. If I had an interface check buried somewhere deep in a library and all of a sudden some unexpected classes started to pass that check it could cause unexpected results.

alrz commented 8 years ago

@HaloFour These are known as trait objects in Rust, when you define a function like void F(IHasM obj) the object in question, obj in this case, keeps a pointer to the concrete type provided vtable, hence you can pass any object to this funciton. while Rust does this for all trait objects, C# can only consider it for trait types and disallow it for interfaces, for example,

class A { public void M() { } }
trait THasM { void M(); }
interface IHasM { void M(); }
implement IHasM for A { }
implement THasM for A { }

void F1(IHasM arg) { }
void F2(THasM arg) { }
void F3<T>(T arg) where T : IHasM { }
void F4<T>(T arg) where T : THasM { }

F1(new A()); // error as usual
F2(new A()); // ok, vtable pointer
F3(new A()); // ok, virtual extension methods
F4(new A()); // ok, same as above
HaloFour commented 8 years ago

@alrz I've not seen much discussion regarding the actual implementation of traits in C# but I don't think it would be comparable to Rust. I'd imagine that they'd be more like traits in Scala: interfaces where some of the methods provide default implementations, and that types are then required to extend directly. You'd need those method slots in the vtable to exist in order to be able to dispatch them, and classes that don't adopt that trait directly would not have those slots. An external mechanism would not be able to modify the vtable of an instance, especially of a type outside of that assembly. You'd also have generic type constraints enforced by the CLR which would definitely forbid all of F2 through F4.

I just don't see it. Rust doesn't fit on the CLR.

alrz commented 8 years ago

@HaloFour Just like #258 this needs CLR support of course, but if F2 case doesn't fit on the CLR we'd be ok with generic constraints. I'm not saying that this should be done exactly the Rust way, but C# can adopt some part of it to cover some use cases in the language. I've seen much discussion regarding traits (not much about implementation though) but people want them to do zero-cost abstraction over primitives for example (as I said there are a lot of proposals regarding this matter) and they want get Haskell out of it but I think Rust approach is most applicable to C#. Closest proposal that I find was #3357 which doesn't add up much IMO.

HaloFour commented 8 years ago

@alrz That it does, but that's a much more limited change to the CLR keeping to the existing type structure and vtable mechanics. The only difference there being that if an interface defines a member that a concrete class does not implement but there is a known default implementation for the interface that the CLR will automatically assign the default implementation to the existing vtable slot rather than failing to load the type. Your proposal, however, is a very aggressive change involving adding a completely new form of typing (traits) as a first-class citizen along-side interfaces and basically making vtables a free-for-all.

My opinion is that I don't like the extent to which this proposal goes and I don't like that any type anywhere could have it's core contracts amended by an external actor. I don't think that someone should be able to arbitrarily fake that System.Int32 implements alrz.IFoo. Seems ripe for abuse and could cause bizarre subtle effects if isinst suddenly reports unexpected types as being some interface.

alrz commented 8 years ago

@HaloFour That's not where I'm going with this, I just mentioned traits to make my point and clear up the differences, as I said this has nothing or a little to do with traits but the syntax is flexible enough to be reused for trait implementations as well. You can think of implement as a group of extension methods for a type, as an entity, so it just makes extension methods more coherent and you don't need to rely on the minimum level of coherence of SomethingExtensions and we would take advantage of duck typing along the way. I'm utilizing virtual extension methods on a higher level, that's it. I'm thinking that isinst wouldn't behave otherwise (at least not for interfaces), however, "any type anywhere could have it's core contracts amended by an external actor" this is true for traits and that's why they're useful, there’s a restriction on implementing traits though, either the trait or the type you’re implementing it for must be defined in the same assembly. I don't see why it would be problematic as it's not in the other languages.

HaloFour commented 8 years ago

@alrz Whatever it's called I'd think that the CLR mods would remain the same. You couldn't achieve F2 without some kind of new typing mechanism. You couldn't achieve F3 without the CLR officially supporting duck typing, which is the only way you'd satisfy the generic type constraint, and I don't see how that would (or should) be possible without affecting how the isinst opcode behaves. You couldn't achieve F4 without expanding generic type constraints over this new typing mechanism.

Rust's type system is trait-based, the CLR is not. Rust owns it's type system outright, C# does not. I just don't see such radical changes to the CLR happening rather than a more iterative approach to support traits/mixins as done by other languages more similar to C# where a class must directly adopt the trait.

alrz commented 8 years ago

@HaloFour That restriction would apply to interfaces too. I think it addresses your concerns regarding implementation and usage, but it largely depends on #258 and what it adds to the CLR.

alrz commented 8 years ago

Even without interfaces, I think extension syntax is a good alternative for defining extension members.

internal extension Random {
  public bool NextBool() {
    return this.Next(2) == 0;
  }
}

// instead of 
internal static class RandomExtensions {
  public static bool NextBool(this Random self) {
    return self.Next(2) == 0;
  }
}

This'd also work for extension properties, operators etc. I'll update the opening post to use this syntax.

HaloFour commented 8 years ago

@alrz

Maybe, but we already have a syntax for extension methods. Also, if written like an instance method it might be even less clear that the this argument can be null. Not that this is particularly obvious now.

alrz commented 8 years ago

@HaloFour Currently, I don't know how extension properties would look like, because there is no way to define this T self on properties! Besides, with this we get more coherent extension members around types. And as for your argument about nulls, I think we are making progress to not fall into that!

HaloFour commented 8 years ago

@alrz

Yes, we already have a proposal covering extension properties (and other things): #112 That's not related to some kind of "extension implementation" though so it doesn't seem particularly related to this proposal.

And as for your argument about nulls, I think we are making progress to not fall into that!

Sometimes this is quite intentional, but indeed #227 would at least be able to warn when it's not. Either way since extension methods are static and not invoked via callvirt they don't get the built-in null check. The CLR doesn't require that at all, either. Technically C# could use call to invoke non-virtual instance methods and the this argument would be null legally, although the C# spec says that it won't.

alrz commented 8 years ago

@HaloFour Yes it's not particularly related but I wanted a syntax to cover both cases and be consistent with C# overall (implement is not). Also, from SO:

this can never be null, unless the method was called using a call instruction in hand-written IL.

So there is no CLR enforcement. Technically, you should check this for null. I'm just sayin.

HaloFour commented 8 years ago

@alrz

So there is no CLR enforcement. Technically, you should check this for null. I'm just sayin.

Technically, yes. You never know what language might be calling your assembly. And #227 will do nothing to help that. :smile:

gafter commented 7 years ago

We are now taking language feature discussion in other repositories:

Features that are under active design or development, or which are "championed" by someone on the language design team, have already been moved either as issues or as checked-in design documents. For example, the proposal in this repo "Proposal: Partial interface implementation a.k.a. Traits" (issue 16139 and a few other issues that request the same thing) are now tracked by the language team at issue 52 in https://github.com/dotnet/csharplang/issues, and there is a draft spec at https://github.com/dotnet/csharplang/blob/master/proposals/default-interface-methods.md and further discussion at issue 288 in https://github.com/dotnet/csharplang/issues. Prototyping of the compiler portion of language features is still tracked here; see, for example, https://github.com/dotnet/roslyn/tree/features/DefaultInterfaceImplementation and issue 17952.

In order to facilitate that transition, we have started closing language design discussions from the roslyn repo with a note briefly explaining why. When we are aware of an existing discussion for the feature already in the new repo, we are adding a link to that. But we're not adding new issues to the new repos for existing discussions in this repo that the language design team does not currently envision taking on. Our intent is to eventually close the language design issues in the Roslyn repo and encourage discussion in one of the new repos instead.

Our intent is not to shut down discussion on language design - you can still continue discussion on the closed issues if you want - but rather we would like to encourage people to move discussion to where we are more likely to be paying attention (the new repo), or to abandon discussions that are no longer of interest to you.

If you happen to notice that one of the closed issues has a relevant issue in the new repo, and we have not added a link to the new issue, we would appreciate you providing a link from the old to the new discussion. That way people who are still interested in the discussion can start paying attention to the new issue.

Also, we'd welcome any ideas you might have on how we could better manage the transition. Comments and discussion about closing and/or moving issues should be directed to https://github.com/dotnet/roslyn/issues/18002. Comments and discussion about this issue can take place here or on an issue in the relevant repo.


This particular feature request would be satisfied by a combination of type classes (https://github.com/dotnet/csharplang/issues/110) and default interface methods (https://github.com/dotnet/csharplang/issues/52), both of which are under consideration for C#.