-
Notifications
You must be signed in to change notification settings - Fork 207
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
Replace record fields ($1
, $2
, etc.) with named fields if type contains field names (eg. (int a, int b)
)
#3487
Comments
Static names make sense in arguments and records (not surprisingly since they are closely related). The idea is that a static type with positional entries, either the static parameter list of a function type or the static record type, can contain names for the positional entries, purely for documentationation purposes today, so we might as well use those names for something practical. For argument lists, we could allow you to pass a positional argument by name. int limit(int value, int min, int max) => ...
... limit(x, max: 100, min: 0)... The effect is exactly the same as passing the same value positionally, all it gives you is control over evaluation order, and documentation. For records there are two ways the name can be used:
Those are both a little more problematic than the argument list. The destructuring because it can conflict with extension members, in which case the extension would win. The creation because the syntax is already valid, and has its meaning changed by the context type. The general argument against doing anything like this is that it makes changing the name of a positional parameter or record field a breaking change. I think I'd suggest that positional names from other libraries are not inferred into the current library. If you need a type with names, you have to write it yourself, so it's a shorthand that only works within a single library. |
I definitely understand the desire for this feature. It seems like the names are right there and we should be able to hang useful behavior off them. But my feeling is that doing so would be very brittle and fail in really confusing ways because the names, fundamentally, are not part of the static type. The language spends many pages of the spec precisely defining static types and specifying how they flow through programs. The whole language rests on that. Once we start having behavior that rests on static properties of some code that aren't part of the type, we either have to recapitulate all of that complexity, accept that the behavior won't feel as seamless as other features, or go all the way and actually make these static properties part of the type. For example, we could try to make this work: test() {
(int a, int b) v1 = (1, 2);
print(v1.a);
(int x, int y) v2 = (3, 4);
print(v2.x);
} But what happens when a user tries to do: test(bool condition) {
(int a, int b) v1 = (1, 2);
(int x, int y) v2 = (3, 4);
print((condition ? v1 : v2).a); // ?
print((condition ? v1 : v2).x); // ?
} Or: T identity<T>(T value) => value;
test() {
(int a, int b) v1 = (1, 2);
var v2 = identity(v1);
print(v2.a); // Does this still work?
} Or: (T1, T2) identityPair<T1, T2>((T1, T2) value) => value;
test() {
(int a, int b) v1 = (1, 2);
var v2 = identityPair(v1);
print(v2.a); // Does this still work?
} Or: (T1, T2) identityPair<T1, T2>(T1 value1, T2 value2) => (value1, value2);
test() {
(int a, int b) v1 = (1, 2);
var v2 = identityPair(v1.a, v1.b);
print(v2.a); // Does this still work?
} Examples like this are rife. The problem is that static types are basically always flowing through a program between where they appear and where they're used. So unless we figure out precisely how these field names would flow through all of that, they'll probably get dropped on the floor so often that they aren't actually useful. And if we do try to make the field names flow through types, then we'll quickly run into problems like the conditional one here where now two record types that used to be identical now may or may not be (or we have to drop the names on the floor at that point). Overall, my suspicion is that's just not worth it. If you want named fields, that's what named fields are for. That's why we added them! :) You can just do: ({int a, int b}) v = (a: 1, b: 2);
print(v.a)
print(v.b); |
This doesn't work either, because Dart doesn't have duck-typing: class A {
final int a;
final int b;
A({required this.a, required this.b});
}
class B {
final int a;
final int b;
B({required this.a, required this.b});
}
void test(bool cond, A a, B b) {
final c = cond ? a : b;
print(c.a);
} However, I was in fact thinking assuming the variable names were part of the type of a record. Duck-typing has always made the most intuitive sense to me, although I am aware that this has ramifications for enforcing common storage layouts to ensure speed of execution, etc.
That's fair, although the type declarations can become gnarly with this syntax (especially when you have nested records, which I have used a couple of times...) |
That doesn't work, but this does: test(bool condition) {
(int a, int b) v1 = (1, 2);
(int x, int y) v2 = (3, 4);
print((condition ? v1 : v2).$1); // 1 or 3
print((condition ? v1 : v2).$2); // 2 or 4
} Records are structurally typed (i.e. "duck typed") and positional fields only need to have matching types for two records to have the same type.
They are for the named fields in a record, but not the positional fields: (int a, int b) pair1 = (1, 2);
(int x, int y) pair2 = pair1; // OK.
({int a, int b}) namedPair1 = (a: 1, b: 2);
({int x, int y}) namedPair2 = namedPair1; // Error.
Yeah, records were carefully designed so that even though they are structurally typed, the compiler should always be able to compile field accesses on them to something fairly efficient.
My general rule for records is that if I find a record type declaration is getting too verbose, that's probably a signal that it's time to make an actual class and give it a real nominal type. |
I'm going to go ahead and close the issue because this is working as intended, but thank you for bringing this up to discuss. |
Actually, after thinking about this more on the drive home, maybe I closed it prematurely. :) |
Seen While I expect a lot of us to know bash I don't think it is the best source of inspiration for a language and be beginner friendly especially when looking at the rest of the language and considering Dart could be a first language for someone. Been able to access |
@stephane-archer About the index question, see #2638. |
Also, to avoid the reader of your code making this mistake, I'd recommend not using names at all, or restructuring: (int, int) getNums() => (1, 2);
// instead of:
final (int a, int b) v = getNums();
// try one of these:
final (int, int) v = getNums();
final v = getNums();
final (a, b) = getNums(); In my opinion, giving names in contexts where they can never be used like this should not have been allowed, as it leads to confusion about why they cannot be used. Or at least, records that are given names in the context of a variable should have their members be accessible by those names: final (int, int) v = getNums(); // print(v.$1)
final v = getNums(); // print(v.$1)
final (int a, int b) = getNums(); // print(v.a) @munificent would that work or be too confusing? |
I completely agree with: final (int, int) v = getNums(); // print(v.$1)
final v = getNums(); // print(v.$1)
final (int a, int b) = getNums(); // print(v.a) I really want to name my field access because Also regarding typedef typedef Hello = (int height, int with, int scale, int somethingElse)
void foo(Hello h) {
h.somethingElse; // is so much better than h.$4
} |
In cases where you have control over the record definition, use curly braces to indicate real named fields: typedef MyRecord = (int unnamed, {int named});
void foo(MyRecord record) => print("With name ${record.named}, without name ${record.$1}"); (Also, for future reference, you need three backticks (`) to make a code block, not single quotes (') |
typedef Hello = ({int height, int with, int scale, int somethingElse}) solve a large part of the issue because I can use I like the approach short to write but explicit with a quick-fix But to me, this is the best I found so far because named parameters are so much better than final (int, int) v = getNums(); // print(v.$1)
final v = getNums(); // print(v.$1)
final (int a, int b) = getNums(); // print(v.a) @Levi-Lesches my phone seems to not want me to put 3 ` in a text field... (iPhone) |
another use case: void foo ((String inputPath, String outputPath) message) {
print(message.inputPath)
} |
This is something completely different and is tracked in #3001. |
I have some concerns; currently Would this change affect the types, or is it more akin to extension types, where the types and values are the same, and you just happen to have extra getters? Kind of like; extension type AB((int a, int b) ab) {
int get a => $1;
int get b => $2;
}
extension type XY((int x, int y) xy) {
int get x => $1;
int get y => $2;
} But then, wouldn't it need to be a type thing in order to be consistent with the language? So; would Consider extensions; don't named positional fields have to be type based so that you can use them in an extension? extension on (int a, int b) {
int get a2 => a * 2;
int get b2 => b * 2;
} Or would it just be more of a syntax sugar thing, where referencing the name of a positional variable just compiles down to the actual It could still be that |
Nope. that is not destructuring. notice it says message.inputPath, not |
Sorry if not everything is related to this exact issue, I try my best to stay on the subject: void foo ((String inputPath, String outputPath) message) {
print(message.$1)
print(message.$2)
} versus: void foo ((String inputPath, String outputPath) message) {
print(message.inputPath)
print(message.outputPath)
} now let's add a middle argument and print it last ;) void foo ((String inputPath, String simpleChange, String outputPath) message) {
print(message.$1)
print(message.$3) // $2 became $3 (tricky to spot on a large code review where there is no change on the line)
print(message.$2) //What is the position of simpleChange to be able to use it? Best question to ask yourself Friday night before the Christmas weekend
} versus void foo ((String inputPath, String simpleChange, String outputPath) message) {
print(message.inputPath)
print(message.outputPath)
print(message.simpleChange) // only the last line has changed
} I think the |
If the names in a tuple type declaration are not even used currently, what harm is there in allowing the names to be used to access fields within that scope? It won't break anything. Named parameters could act as an alias for |
@stephane-archer All these problems with positional fields are the exact reason why records also support named fields! And the verbosity of named fields is the exact reason why records also support positional fields! Named fields are clearly a more natural fit for the usecases presented, so I think this comment alludes to the actual problem:
Roughly:
In that case, it probably makes more sense to discuss how to produce records in a concise way, rather than try to add naming semantics to fields which are anonymous by definition. You can't get shorter than In practice you're typically producing a record from variables rather than literals - for example I see lots of code that looks like In general though, my personal heuristic for when to use positional fields is basically:
If any of these aren't true, I normally use a named field. |
@Mike278 I think you summarized the issue:
You want named fields when consuming and usually, positional fields when producing.
should be produced with and being able to consume with named fields: Using named field and making a helper function is the best we have today to achieve this but I will be glad if we can move from |
Positional-named fields are more useful with something like In this case, I may create an extension for that record. For consumption, it may be useful to have the name for a positional field be used to construct an artificial getter pointing to the right field. If course, you could make it named, which would certainly work, but it's also a marginally worse API when having 3 values out together should arguably be enough. You could perhaps use an extension type, but you can't currently "implement" a record type even though you should be able to, and we're also effectively in class territory at this point. |
I think it could be useful to consider a basic assumption. Perhaps a record type isn't always the best choice for these situations? Let's just compare it to a class. I'm assuming primary constructors in a form which is similar to this proposal such that the declaration of the class can be concise (but I'm also showing the longer form that we can use today): // Consider this recent example.
typedef R = ({int height, int width, int scale, int somethingElse});
// A class doing a similar job.
class const C(int height, int width, int scale, int somethingElse);
/* If we don't have primary constructors we can write it like this.
class C {
final int height, width, scale, somethingElse;
const C(this.height, this.width, this.scale, this.somethingElse);
}
*/
void main() {
// Producing the record.
var r = (height: 1920, width: 1080, scale: 1, somethingElse: 42);
// Producing the class instance.
var c = C(1920, 1080, 1, 42);
// Destructuring to use the height and the width.
{
var (:height, :width, scale: _, somethingElse: _) = r;
print(height + width); // or
print(r.height + r.width);
}
{
var C(:height, :width) = c;
print(height + width); // or
print(c.height + c.width);
}
} In particular, the class will quite naturally allow for creation of entities using positional parameters and destructuring using names. It could be argued that the object pattern is inconvenient if the class name is long. However, if we get #4124 then we can use a single character when the type is available as the context type: class const ClassWithALongerName(int height, int width, int scale, int somethingElse);
void main() {
var _(:height, :width) = ClassWithALongerName(...);
} There could be several reasons why the good ol' class can compete with the record type: The short form where the object is constructed with positional arguments is standard, but we can also declare other constructors if we wish to have a named form as well, or any mixture of named and positional parameters. The class type is nominal, which means that it won't be confused with other types that are conceptually different just because they use the same names (or types and positions, for positional getters). The record can only be destructured if all getters are mentioned (#3964 tries to improve on this one, though). Finally, the class is faster because the record works like a generic class (for instance, it must be assignable to the type Don't worry, I'm not saying that we shouldn't do anything about this particular corner of the record type feature. ;-) At the same time, I think it may be useful to keep other mechanisms like classes in mind. |
Returning to the request in the original posting, note that the ability to use the otherwise-just-documentation names of positional members of a record type corresponds precisely to a set of extension getters: // Assume that we have this declaration.
(int a, int b) v = (1, 2);
// Assume there's a way to get the following, implicitly.
extension on (int, int) {
int get a => $1;
int get b => $2;
} So, as long as the extension is in scope in exactly the locations where we want it to be in scope, it just works. ;-) We could use this as a starting point for a feature: The compiler would generate something like the extension declaration shown above, and it would be considered accessible exactly in the case where the receiver is that This doesn't immediately generalize, though: What would we do if the type // Perhaps ... ?
void foo<X extends Map<int, (int a, int b)>>(X x) {
var theA = x[14].a; // Relies on the implicitly induced extension on that `(int, int)`.
var thePair = x[14];
thePair.b; // The inferred type of `thePair` enables the extension, too.
} I don't know if there is a satisfactory solution to the question about 'where is this extension accessible?', but it could "work" to make it accessible in a superset of the optimal locations. At the end of that path we could just write the extension manually, and live with the fact that it can be used with all records of type @TekExplorer already mentioned extension types here. An extension type would need to be obtained explicitly (e.g., we would use We could use implicit constructors to avoid the explicit transition "into" the extension type. extension type const AB._((int, int) _record) {
implicit AB((int, int) pair) : this._(pair);
int get a => _record.$1;
int get b => _record.$2;
}
void main() {
AB v = (1, 2); // OK, implicitly means `AB v = AB((1, 2));`.
var sum = v.a + v.b; // OK.
v.$1; // Error, no such member.
} |
I think that has promise with implicit constructors. I think we'd just need to be able to "implement <record type>" for the free and easy destructuring, which is not currently allowed, then I think we'd have all the functionality we need. Then we'd just need to work out an easy way to define them. Probably a macro. The best solution is to have the compiler make it up for us, but I don't see an easy path to that |
I'm not entirely convinced of this myself. Don't get me wrong, I do think implicit constructors are a good idea, and I would like to see them added to the language, but I don't think it solves this particular problem that well. IMO having to define an extension type with named getters and an implicit constructor just to get more descriptive names than
I have an existing issue for this exact feature if you are interested. #3839
Do macros even have access to the names of the positional fields of records? I don't know the answer to this but I suspect they may not. |
I don't know exactly how the analyzer is working here, but I'd say whenever the type of a variable can be traced back to a record type that has positional names, this magic extension should be visible. So this wouldn't just be true based on the type of record, but rather where the record came from, and would cover local variables, fields, parameters, and type parameters. That is, if the analyzer keeps the names in all these cases and doesn't just reduce it down to |
Macros arent done yet, and I imagine it would be relatively trivial to add the information, since we already know it exists. Magic extensions would probably be the most effective solution for this particular issue, but may be complicated to implement. |
The way I would formally specify something like this (which we have to do, no amount of hand waving is enough) would be:
The Devil in those details is fixing all the options on types that have to preserve or combine names, and make sure they do so. And that all accesses behave the same. If we get record spreads, we'll have to drop names that would cause conflicts. So we get an infinity of equivalent types in the static type system, just to allow you to write SGTM! I already want the same for positional function arguments, so you can write |
Given the declaration
you can access
v.$1
andv.$2
, but I would have expected to be able to accessv.a
andv.b
(these fields don't exist).I would expect
$1
and$2
to not be visible or usable for the type(int a, int b)
, since there shouldn't be two ways to access the same value.I would only expect to have to use
$1
and$2
if the fields were not named, e.g.:@mraleph stated in another issue (which I branched this separate issue out from):
The text was updated successfully, but these errors were encountered: