Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Provide generalized language features in lieu of tuples #12654

Closed
andrew-skybound opened this issue Jul 21, 2016 · 12 comments
Closed

Comments

@andrew-skybound
Copy link

Related issues: #98, #347, #3913

Most of the talk about tuples in C# 7 has been focused on providing language support for compiler-generated tuple types or something like System.ValueTuple. In this proposal I will suggest two alternative, and more generalized, features that are designed to work together to provide nearly the same benefit as the previous tuple proposals, but with additional flexibility that allows the new syntax to be used with existing class models, previous versions of the CLR, and other programming languages.

These are the two language features being proposed. They can be implemented and tested independently of each other, and each is generally useful on its own.

  1. Untyped value lists
  2. Generic argument name annotations

1. Untyped value lists

Parenthesized value lists, like anonymous functions, could have no type. Rather, they would be implicitly convertible to a compatible type, where a compatible type is defined as one having a constructor overload callable using the values in the list. This is roughly how lambda expressions are specified, and while it may not lead to succinctness on par with more functional languages, it is extremely flexible and transparent. For example, the flexibility of this design has allowed the same lambda expression syntax to construct instances of both delegates and query expressions.

Untyped parenthesized lists are more generally useful because they can be used with all kinds of types, not just tuples. For example:

// when declaring variables
Rectangle bounds = (0, 0, 100, 200);

// when calling methods
context.DrawLine((0, 0), (100, 200));

// when returning values
public Point FlipXY(Point input) => (input.Y, input.X);

// when initializing arrays
var coords = new Point [] {
    (1, 1),
    (2, 2),
    (30, 30),
    };

With untyped value lists, we no longer need to decide whether tuple syntax should create reference or value types—it is simply determined by the target type, and therefore up to the developer. Nor do we need to choose between mutable and immutable tuple types. In cases where mutable tuples are appropriate, or reference type tuples are determined to be a performance bottleneck, it is relatively easy to change a method signature use ValueTuple instead of Tuple and continue using the same construction syntax. This allows developers to even define and use their own "tuple" types where appropriate, customizing GetHashCode() and Equals(), implementing interfaces, etc.

Tuple<int, string> value1 = (1, "apple");
ValueTuple<int, string> value2 = (2, "pear");
KeyValuePair<int, string> pair1 = (3, "orange");
CustomPair pair2 = ("answer", 42);

Optional features

Here are some additional syntax ideas for this feature, though I don't see a strong enough use case for any of them to be implemented (at least not right away).

  1. Allowing named arguments to be used in untyped value lists.
  2. Allowing single-value lists with the presence of a trailing (or preceding) comma.
  3. Allowing zero-item lists, where () would be equivalent to (and shorter than) default(TypeName) or even new TypeName().
  4. Allowing object initialization expressions to appear after the value list.

2. Generic argument name annotations

When constructing a generic type, an optional identifier could be allowed to appear after each type argument. For example:

Tuple<int Number, string Name> t = (1, "apple");
Assert.AreEqual(t.Number == 1);
Assert.AreEqual(t.Name == "apple");

The compiler can use the optional identifier to rename certain members of the type. In the case above, the compiler has renamed the properties of Tuple with the names provided in the type arguments. This could be made possible by applying various attributes to the type itself that tell the compiler where to use the name. In the case of Tuple<T1,T2>, it might look something like this:

public class Tuple<[NamedArgument] T1, [NamedArgument] T2>
{
    public Tuple(T1 item1, T2 item1)
    {
        this.Item1 = item1;
        this.Item2 = item2;
    }

    [NameOfGenericArgument(1)]
    public T1 Item1 { get; private set; }

    [NameOfGenericArgument(2)]
    public T2 Item2 { get; private set; }
}

The compiler would not generate any new types, of course, rather the renamed members would simply be accessible with a different name.

This is useful for all sorts of types. For example, adding annotations to KeyValuePair<TKey,TValue> would allow a user to apply meaningful names to the Key and Value properties. Ideally, name annotations would flow through the type inferencing algorithm, allowing cases like this:

var dictionary = new Dictionary<string Name, decimal OutstandingAmount> {
    { "John", 10000m },
    { "Peter", 15000m },
    };

foreach (var item in dictionary)
{
    // note how the "Name" annotation flows through to KeyValuePair
    if (item.Name == "John")
        Assert.AreEqual(item.OutstandingAmount == 10000m);
}

Use with generic delegates

Delegate types could also be annotated using the same syntax:

public delegate TReturn Func<[NamedArgument] T1, [NamedArgument] T2, TReturn>(
    [NameOfGenericArgument(1)] T1 arg1,
    [NameOfGenericArgument(2)] T2 arg2);

Then we can apply meaningful argument names to delegate functions. For example, an overload of LINQ's "Where" method might look like this:

public IEnumerable<T> Where<T>(this IEnumerable<T> sequence, Func<int index, T item, bool> predicate)
{
    // here we can call "predicate" using named parameters:
    //  predicate(index: 1, item: "abc")
}

// Outside, the caller will see those arguments names in the intellisense tip
// A smart editor could insert meaningful argument names upon "tab" completion:
items.Where( [[Tab]]
items.Where((index, item) =>

One possible "social" benefit of this feature is that it will encourage developers to reuse framework types where appropriate, rather than declaring their own, leading to more consistent and compact code. At this point, a member or parameter having a type of Func or Action with more than about 3 arguments is quite unwieldily. Consider:

    static Color Adjust(this Color color, Func<double, double, double, double, Color> adjustment) { ... }
    // vs:
    static Color Adjust(this Color color, Func<double red, double green, double blue, double alpha, Color result> adjustment) { ... }

Persisting name annotations in signatures

Argument name annotations that appear in member signatures would need to be persisted somehow in metadata. This was addressed for tuples in #3913, and here is how it could be implemented more generally for generic argument name annotations:

// input
public Tuple<int X, int Y> GetXY(Func<int Value, Tuple<string Text, IComparer Comparer>> someFunction);

// output
[return: GenericArgumentNames("X", "Y")]
public Tuple<int, int> GetXY([GenericArgumentNames("Value", new [] { "Text", "Comparer" })] Func<int, Tuple<string, IComparer>> someFunction);

Compared to the other proposed syntax

Despite Tuple<int X, int Y> being more verbose than (int x, int y), the semantics of the shorter syntax are not obvious. Is it a reference type or value type? Can it be marshalled to native code, and is it blittable? Is it serializable? Do those identifiers represent fields or properties, and are they readonly or volatile? If they are mutable fields, can they be passed by reference? If I pass one field by reference, or capture it in a lambda, does this prevent the other one from being collected by the GC? I am sure the design team is capable of arriving at a best-case answer for all these questions. But an ordinary programmer, even an experienced programmer, will certainly not immediately know the answer to these questions by looking at (int x, int y). He will when he sees Tuple<int X, int Y>.

Optional features

NameOfGenericArgumentAttribute could be extended to accept a kind of template string instead of a simple generic argument number, allowing multiple members to be customized with the same argument name. This offers some interesting possibilities, take for example this generic immutable type, which provides "Set" methods to transform itself (also note the use of untyped value lists in the "Set" methods):

sealed class ImmutablePair<[NamedArgument] TFirst, [NamedArgument] TSecond>
{
    public ImmutablePair(TFirst first, TSecond second)
    {
        this.First = first;
        this.Second = second;
    }

    [NameOfGenericArgument(1)]
    public TFirst First { get; private set; }

    [NameOfGenericArgument("Set{1}")]
    public ImmutablePair<TFirst, TSecond> SetFirst(TFirst value) => (value, Second);

    [NameOfGenericArgument(2)]
    public TSecond Second { get; private set; }

    [NameOfGenericArgument("Set{2}")]
    public ImmutablePair<TFirst, TSecond> SetSecond(TSecond value) => (First, value);
}

The name of the members of this class could be customized by its creator:

// name the first property "Target" and the second one "BackColor"
ImmutablePair<Visual Target, Color BackColor> assignment = (textBox, Colors.White);

// properties are accessible in the usual way
var target = assignment.Target;

// here the "SetSecond" method has been renamed "SetBackColor", based on the template string "Set{2}"
var multiplied = assignment.SetBackColor(Color.Multiply(assignment.BackColor, 0.5));

What's wrong with the current tuple proposal?

  • It defines yet another category of anonymous type. We already have System.Tuple, System.ValueTuple, anonymous types, and maybe records. None of these are compatible with each other; each has its own syntax and (hard-coded) semantics.
  • It's going to provide a poor experience in other languages. Sure we can add the same type of tuple feature to VB, but F# already reuses System.Tuple guaranteeing it will be forever incompatible, and the .NET universe is much larger than the three most popular languages. Adding compiler-generated anonymous tuples will lead to the same problem that class library authors have with F#, namely that a class library written in F#, when exposed to other languages, looks F#-ish and lacks that plain-vanilla CLS library feel that you can only really get with C#.
  • The semantics and layout of compiler-generated types are black boxes that are impossible to customize. By reusing existing types, the semantics are obvious and consistent, and can be customized when required.

Language syntax that is usable with all types that follow a certain structure is more generally useful than syntax that is restricted to a specific type. C# has followed this principle to advantage in the past and I believe it is natural to continue to do so. For example:

  • foreach does not require an object to implement IEnumerable, only that it declares a GetEnumerator() method
  • from..in..select does not require an instance to implement any kind of interface, only to have instance methods in scope with certain names and signatures
  • await does not define or require any kind of IAwaitable interface

Sometimes linking a feature to a specific type is unavoidable (for example, generators are required to return some kind of IEnumerable<T>, and async methods that return a value must return Task<T>). But where we can avoid this, indeed we should.

Perhaps if we were starting from scratch, and there was no existing .NET framework, it would make sense to bind the tuple syntax to a specific tuple type that was used throughout the framework. Or, if C# was more functional in nature and specifically required tuple constructors to be as short as possible, it would make sense to bind to a specific tuple type. But this is not our situatation. C# has never been the most succinct language on the block, rather it has excelled in finding a good balance between being succinct and explicit.

This proposal requires very few changes to the .NET class library, no changes to the runtime, and could provide an immediate benefit to authors both targeting and consuming previous versions of the framework.

@dsaf
Copy link

dsaf commented Jul 21, 2016

Some valid concerns raised, but I am not sure about custom attribute compiler magic.
I would however like to see a better language-feature-neutral IDE support for custom attributes:

// Outside, the caller will see those arguments names in the intellisense tip
// A smart editor could insert meaningful argument names upon "tab" completion:
#711

This would certainly be more useful than e.g. reference counts http://stackoverflow.com/questions/17847927/how-to-hide-reference-counts-in-vs2013

@HaloFour
Copy link

I think you misinterpreted the tuple proposal:

  1. Tuples don't provide another form of anonymous type. Tuples are a syntax around both the existing System.Tuple types and the newer System.ValueTuple types.
  2. F# made a lot of decisions that don't play well with other languages, and this will become painfully apparent the more C# wades into functional territory. However, as the tuple system proposed is more generic, it does seem that C# will easily be able to consume System.Tuple using the same tuple syntax, and it wouldn't surprise me if F# did the same with System.ValueTuple.
  3. The compiler isn't generating any new types.

Tuple deconstruction is also general purpose and I believe that the compiler will allow positional deconstruction with any type that resolves an instance (or extension) method Deconstruct. I think that the compiler might also special-case some existing types such as System.Tuple and System.Generic.Collections.KeyValuePair. There was also talk of using tuple syntax to construct arbitrary types, e.g. KeyValuePair<int, string> kvp = (1, "hello"); but I don't know if that went anywhere.

@andrew-skybound
Copy link
Author

@HaloFour Syntax around existing framework types is good. I think my misunderstanding on that point was based on this section of #347 and the discussion that followed:

Even so, it would probably come as a surprise to developers if there was no interoperability between tuples across assembly boundaries. This may range from having implicit conversions between them, supported by the compiler, to having a true unification supported by the runtime, or implemented with very clever tricks. Such tricks might lead to a less straightforward layout in metadata (such as carrying the tuple member names in separate attributes instead of as actual member names on the generated struct).

This needs further investigation.

But it's not clear to me how the proposed syntax could support both System.Tuple and System.ValueTuple. What type does "point" have in the statement var point = (x: 0, y:1);?

Also, if it does in fact work with both kinds of tuple types, then what is the benefit of restricting the syntax to two specific types, versus a general-purpose syntax that works with any type that follows a specific pattern?

In regards to tuple deconstruction, I thought this had been put on the back burner for now so I left it out:

(from #3913)

For now we sidestep the question by not adding a deconstruction syntax. The names make access much easier. If it turns out to be painful, we can reconsider.

However, since we're talking about deconstruction there is one minor issue I have with the Deconstruct method design, and that is that out arguments are not covariant like return values are. That means the types of the target variables must match precisely the type of objects in the tuple.

// given this signature
public (Dog dog, DogFood food) GetDogAndFood() { .. }

// this is not allowed:
(Animal a, Food f) = GetDogAndFood();

// but this is:
var result = GetDogAndFood();
Animal a = result.dog;
Food f = result.food;

I understand why tuple types cannot be covariant, but when the values are deconstructed into local variables, this restriction is surprising and unfortunate.

Getting off topic here, but if you are set on a Deconstruct() method with out arguments, rather than a special deconstruction syntax I think "declaration expressions" would offer the same benefit in a more generally useful way, and the explicit use of the out keyword would make it clear that covariant returns are not allowed.

@HaloFour
Copy link

@andrew-skybound

What type does "point" have in the statement var point = (x: 0, y:1);?

That would result in a System.ValueTuple<int,int> as you haven't told it otherwise.

Also, if it does in fact work with both kinds of tuple types, then what is the benefit of restricting the syntax to two specific types, versus a general-purpose syntax that works with any type that follows a specific pattern?

There was the potential that if you explicitly specified the result type that tuple creation syntax could create that type, but I don't know if that was adopted.

System.Tuple<int, string> tuple = (123, "foo");

void AcceptsTuple(KeyValuePair<string, int> pair) { }
AcceptsTuple(("foo", 123));

In regards to tuple deconstruction, I thought this had been put on the back burner for now so I left it out:

Unfortunately the various proposals and design notes are quite old. Poking around at newer issues shows that deconstruction is in the works. For example #12635 explicitly mentions changing the signature pattern of the Deconstruct methods to return tuples rather than out parameters, which should resolve the variance issue you mentioned. The issue #11299 has a list of proposed/discussed/implemented features for deconstruction in a raw form.

@DerpMcDerp
Copy link

@andrew-skybound

  • What does the following do in this proposal:
var x = ();
var y = ("asdf", 1.0);
  • C# considers implicit conversions, e.g.
Whatever x = blah;
Whatever y = (blah);

doesn't only just consult Whatever(blah) constructors, it also considers implicit converion operators. Does:

Whatever x = (1, 2);

ever consider implicit conversion operators? e.g. what if Whatever has an implicit Tuple<int, int> to Whatever conversion operator?

  • What does the following do:
Whatever foo = Random.CoinFlip() ? (1, 2) : (1, 2, 3);

@andrew-skybound
Copy link
Author

@DerpMcDerp

var x = ();
var y = ("asdf", 1.0);

These two lines would produce a compiler error, according to the proposal. The point is that value lists do not have a type; they are not tuples. They behave the same as lambda expressions, for which this is a compiler error as well:

var x = (x, y) => x + y;

With respect to implicit conversions, no, they have no effect here because value lists do not have a type to begin with.

Whatever foo = Random.CoinFlip() ? (1, 2) : (1, 2, 3);

This would be a compiler error as well, just as lambda expressions cannot be used in the ternary conditional operator without a cast. You would need to write it like this instead:

var foo = Random.CoinFlip() ? (Whatever)(1, 2) : (Whatever)(1, 2, 3);

@andrew-skybound
Copy link
Author

@HaloFour

What type does "point" have in the statement var point = (x: 0, y:1);?

That would result in a System.ValueTuple<int,int> as you haven't told it otherwise.

So tuple expressions default to ValueTuple, but you can cast them to other types as well. Ok. But would you agree that's not really a "syntax around both the existing System.Tuple types and the newer System.ValueTuple types", rather a special case of compiler-generated implicit casting?

For example #12635 explicitly mentions changing the signature pattern of the Deconstruct methods to return tuples rather than out parameters, which should resolve the variance issue you mentioned.

Actually #12635 is referring to the type of a deconstruction expression, not the return type of the Deconstruct method.

@HaloFour
Copy link

@andrew-skybound

I'm not finding much of anything buried in design notes around tuple literals being used to create other types. I have no idea if that concept is actually being implemented. So, until I find out otherwise, tuple literals always create ValueTuple instances.

However, deconstruction can work with any types.

Actually #12635 is referring to the type of a deconstruction expression, not the return type of the Deconstruct method.

That it does, I had misread the very short summary of the issue and interpreted it as a design change. I'm sure you noticed a frustration here about the lack of design notes.

@gafter
Copy link
Member

gafter commented Dec 9, 2016

We're pretty much committed to tuples for C# 7, so we won't be doing anything "in lieu of" them.

@gafter gafter closed this as completed Dec 9, 2016
@aluanhaddad
Copy link

Use with generic delegates

Delegate types could also be annotated using the same syntax:

public delegate TReturn Func<[NamedArgument] T1, [NamedArgument] T2, TReturn>(
     [NameOfGenericArgument(1)] T1 arg1,
     [NameOfGenericArgument(2)] T2 arg2);

Then we can apply meaningful argument names to delegate functions. For example, an overload of LINQ's "Where" method might look like this:

public IEnumerable<T> Where<T>(this IEnumerable<T> sequence, Func<int index, T item, bool> predicate)
 {
     // here we can call "predicate" using named parameters:
     //  predicate(index: 1, item: "abc")
}
 
 // Outside, the caller will see those arguments names in the intellisense tip
 // A smart editor could insert meaningful argument names upon "tab" completion:
items.Where( [[Tab]]
items.Where((index, item) =>

One possible "social" benefit of this feature is that it will encourage developers to reuse framework types where appropriate, rather than declaring their own, leading to more consistent and compact code. At this point, a member or parameter having a type of Func or Action with more than about 3 arguments is quite unwieldily. Consider:

     static Color Adjust(this Color color, Func<double, double, double, double, Color> adjustment) { ... }
     // vs:
     static Color Adjust(this Color color, Func<double red, double green, double blue, double alpha, Color result> adjustment) { ... }

I find these ideas to be resonant.
I would like the ability to name the formal parameters of generic delegate types.

@HaloFour
Copy link

@aluanhaddad

I would like the ability to name the formal parameters of generic delegate types.

Attempting to provide argument names to the generic parameters would just be messy. For starters, there's no correlation between the generic type parameters and the parameters of the actual delegate. Even given the suggestion to use attributes on the delegate definition how would you handle a delegate that used a single generic type parameter for more than one argument? The compiler would have to be pretty strict about how the associations are made, and then the benefit would only really apply to the smallest subset of possible delegates anyway.

When there's ever any doubt I just define a new delegate. It seems pretty unnecessary to come up with a way to decorate the generic delegates with names when defining new delegates is just as simple and not that much more verbose.

public delegate Color AdjustmentDelegate(double red, double green, double blue, double alpha);

static Color Adjust(this Color color, AdjustmentDelegate adjustment) { ... }

@aluanhaddad
Copy link

aluanhaddad commented Dec 28, 2016

@HaloFour The only purpose would be to provide additional documentation. It would not be attached to the delegate definition but rather to the delegate parameter as part of the method to which the delegate is in an argument.

When there's ever any doubt I just define a new delegate. It seems pretty unnecessary to come up with a way to decorate the generic delegates with names when defining new delegates is just as simple and not that much more verbose.

I would very much prefer to use generic delegates unless I specifically want to prevent someone from passing a delegate stored in a reference which is most often a Func or Action. Incompatibility between delegate types is unfortunate. For example

IReadOnlyList<int> GetNumbers()
{
    Predicate<int> even = n => n % 2 == 0;

    IEnumerable<int> numbers = GetValues();

    if (numbers is List<int> list)
    {
        return list.FindAll(even);
    }
    return numbers
        .Where(even) // error
        .ToList();
}

I would like to avoid creating more cases like that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants