dart-lang / language

Design of the Dart language
Other
2.65k stars 201 forks source link

Primary constructor on classes #2364

Open leafpetersen opened 2 years ago

leafpetersen commented 2 years ago

[Update: There is considerable interest on the team in adding primary constructors as a general feature. This original discussion issue has been repurposed as the tracking issue for the general feature request.]

Introduction

Primary constructors is a feature that allows for specifying one constructor and a set of instance variables, with a concise and crisp syntax. Consider this Point class defined using the current class syntax for the constructor and fields:

class Point {
  int x;
  int y;
  Point(this.x, this.y);
}

With the primary constructor feature, this class can defined with this much shorter syntax:

class Point(int x, int y);

Discussion

[original issue content below]

In the proposal for structs and extension structs, I propose to add primary constructors to structs. Briefly, the class name (or the type parameter list if any) may/must be followed by a parenthesized list of variable declarations as such:

  struct MyStruct(int x, int y) {
      // members here
  }

In the struct proposal, these are always final by default, and are restricted in various ways (i.e. they may not be late, they may not be const). They are allowed to declare initializers, which are used to generate default initialization values.

This issue is to discuss the possibility of splitting this out, and making it a general feature for classes as well.

Initial points in favor of this include:

Initial points against include:

Cat-sushi commented 1 year ago

Records already have a primary constructor like syntax, anyway.

Cat-sushi commented 1 year ago

Summary of my opinion.

Hixie commented 1 year ago

I am not familiar with a data classes proposal so I have no opinion to offer regarding this proposal's application to that one.

Cat-sushi commented 1 year ago

Sorry, I've understood the syntax of data classes is not fixed, and I opened new issue #3198.

lrhn commented 1 year ago

There are (at least) three different kinds of language features:

The least one is what we usually call "syntactic sugar", but there's a wonky line beltween "what you cannot do" and "what you cannot reasonably do". It's all Turing equivalent anyway, and we include the lambda calculus, so you can do any computation. You just can't do it inside the language framework we provide (classes, interfaces, well typed functions, etc.)

Primary constructors are firmly in the syntactic sugar group. They do not allow you to do anything you couldn't without them, they just provide a shorter, less repetitive, more up-front syntax which is specialized for a particular use case: Small data-driven classes with little abstraction. You can use the syntax for other classes too, but then the benefit may not be as great, or it may even be negative.

In comparison, class modifiers is in the second group: Allowing you to prevent things that you couldn't prevent before. Those are "easy" to implement, they just reject some programs, and everything else works the same as before (hopefully with some more chances for optimization, now that the compiler has more information).

Null safety is in both of the first two groups. It allows you to express information that couldn't be expressed before (this value can be null, that value cannot), and prevent null-unsafe programs that couldn't be prevented before.

Extension methods are technically syntactic sugar for static functions, but within the language framework, they allow you to express things idiomatically that couldn't be expressed that way before. (We can always argue whether there are design flaws, and whether it's a good thing that extension methods are sometimes better than non-extension methods, even when declared in the same library, but extension methods are being used, successfully, in many domains. And again, specialized synctic sugar doesn't have to be for every case, as long as it works better than what you had before, for the cases where it is intended.)

Patterns are also pure syntactic sugar too, but so concentrated sugar that it allows you to cleanly express something in one line today, that could take ten lines before, and with more code comes more risk of making a mistake. Patterns come with a high complexity cost (learning that new syntax), but we do hope the productivity benefit will outweigh that.

You can argue against individual features, and will probably be correct in several cases, but in every case it was introduced based on the knownledge available at the time, from the requests of developers who wanted the feature, comparison to other possible features solving the same problems, and the available resources. (Rewriting the language from scratch, with everything we know today, may give the best resulting language, but what will we do in the ten years it takes to get there?)

Every feature should be weighted against the cost of designing and implementing it, including opportunity cost and loss of free syntax space, the complexity it introduces for both implementors and users, and the benefit it eventually brings to users.

Low cost, low complexity, high benefit is awesome, but there are only so many low-hanging fruits.

On the other hand, saying "all the good features are taken" and doing nothing is the way to stagnation.

That means that we are often taking on more costly features now than we have before, because there is less syntax space left to use, more existing features to interact with, and few good low-hanging fruits as alternatives. But it should still be features that will be useful to some users. Either because they enable better optimization or better software engineering guarantees by adding restrictions, by providing shorter, or safer, syntax for commonly used programming patterns, or by allowing you to do things you never could before (which usually ends up being changes to the type system, because there's not much you can't do today if you can convince the type system that it's OK.)

Hixie commented 1 year ago

This comparison of JetPack Compose, Swift UI, and Flutter is interesting: https://www.jetpackcompose.app/compare-declarative-frameworks/JetpackCompose-vs-SwiftUI-vs-Flutter A huge amount of boilerplate would be removed by primary constructors syntactic sugar (the rest would largely be removed by having a macros for disposables, and one of the latter examples suggests the need for a macro for inherited widgets, which is not something I think I'd considered before but in retrospect is low hanging fruit).

I would still prefer to see this syntax be introduced in a manner that builds on macros, and failing at least a syntax that avoids the cliff when you switch from primary constructors to anything else, but I understand the value of the proposal.

Hixie commented 1 year ago

With the above in mind I've been trying to think about how I'd use this feature in code (I'm mostly writing sample code these days so it's the kind of code I think would be particularly likely to benefit from this feature).

One thing I've noticed is that it is quite common for constructors to have initializer asserts. I see the current proposal does not support that. It would be unfortunate to have to specify all the fields explicitly just to be able to add asserts to document the class contract. I expect if we don't support initializer asserts in the syntactic sugar for constructors we may have a chilling effect on the user of such asserts, which would be unfortunate given how valuable they have been so far.

eernstg commented 1 year ago

The cliff issue and the support for assertions could be handled by adopting this proposal, which is mentioned in the primary constructor proposal in the discussion section.

The basic idea is that a primary constructor has a magic power (that is, the ability to implicitly induce instance variable declarations), and that specific power could just as well be given to a distinguished non-primary constructor. In that case there can't be a primary constructor, and there can't be more than one of those distinguished non-primary constructors. The distinguished non-primary constructor would have an explicit syntactic marker (in the proposal it's var). We could call it a var constructor.

Except for the fact that some parameters can (and must) induce a variable declaration, the var constructor offers all the normal constructor affordances, including initializer lists that may contain assertions.

The syntax would of course need to be discussed; for example, const var or var const would be the natural syntax at the beginning of a constant var constructor declaration, and this is probably going to give rise to protests. Anyway, the underlying idea can be combined with many different syntactic forms, and we could discuss those two things separately.

n7trd commented 11 months ago

What's wrong with this? I think it is short enough...

class Point {
  int x;
  int y;
  Point(this.x, this.y);
}

Other languages have a lot more surrounding noise around.

Sure, class Point(int x, int y) is shorter, but it's a new syntax for the same thing. I don't know if creating classes faster than ever before is such an important use case for most Dart/Flutter devs 👋 .

Also, how should this be documented reasonably if the list of parameters (ehh fields) grows? Things can quickly get out of hand on the Flutter side. I don't want to blame initializing formal parameters (if that was the name) for the following, but it surely helped.

Bildschirmfoto 2023-10-09 um 00 40 30

jodinathan commented 11 months ago

I don't think ThemeData is a use case for this feature

eernstg commented 11 months ago

Right, cf. this remark.

n7trd commented 11 months ago

I don't think ThemeData is a use case for this feature

You are correct; ThemeData is not an appropriate use case for this feature. I used ThemeData as an example to illustrate how some Flutter API documentation appears from a user's perspective. It's evident that this issue needs to be addressed.

However, this feature appears to worsen the problem by introducing additional functionality, now declarations inside the constructors. My concern is that this might make addressing the issue more challenging.

If the primary purpose and constrain of this feature is to create classes with a limited number of required fields for initialization, then this feature could be beneficial.

eernstg commented 11 months ago

We have discussed various readability issues that may come up in the case where a primary constructor contains a large number of elements. The answer is always "then don't use a primary constructor". With that in mind, it should not be a problem that this mechanism is optimized for cases where it is small and simple. It is purely a piece of syntactic sugar, and nobody needs to use it if they don't like it in a particular case.

cedvdb commented 9 months ago

I'd rather:

class Point {
   Point(int this.x, int this.y);
   Point.onXAxis(this.x) : y = 0;
}
pedromassango commented 7 months ago

Improvements on the OP, replace:

With the primary constructor feature, this class can defined with this much shorter syntax:

To

With the primary constructor feature, this class can be defined with this much shorter syntax:

jodinathan commented 7 months ago

We have discussed various readability issues that may come up in the case where a primary constructor contains a large number of elements. The answer is always "then don't use a primary constructor". With that in mind, it should not be a problem that this mechanism is optimized for cases where it is small and simple. It is purely a piece of syntactic sugar, and nobody needs to use it if they don't like it in a particular case.

I think we do need something that screams that a class is exposing implicit fields, however, maybe we could just add a flag to the class:

// works
primary class Foo {
  Foo(field int x);
}
// exception: you can't have primary constructor stuff without flagging the class as primary
class Foo {
  Foo(field int x);
}
// lint: primary flag not needed
primary class Foo {
  Foo();
}
cedvdb commented 7 months ago

@jodinathan there doesn't need to be two keywords there, imo this is fine

class Foo {
  Foo(field int x);
}

but I prefer

class Foo {
  Foo(int this.x);
}

as it is more familiar

jodinathan commented 7 months ago

yeah, I do like

class Foo {
  Foo(int this.x);
}

but I am not sure if the class declaration should warn that it has magical constructors or not

I had some hard time with TypeScript because of that

lrhn commented 7 months ago

Since

  Foo(int this.x);

is already valid and useful syntax, we can't use that. However, if we prefix it with var or final, then it's, IIRC, not currently valid, and if it is, or won't be missed if we take it for implicit field declarations. So

class Foo { 
  Foo(final int this.x); 
}

would declare a (final) instance variable final int x.

If we don't want it in an initializing formal, it can probably go in the initializer list too

class Foo { 
  Foo(int x): final int x = x; 
}

Not sure how I feel about the readability. Definitely don't want to allow spreading such field declarations across multiple constructors.

rrousselGit commented 6 months ago

I think we're straying away from the initial goal of primary constructors, which is to find a syntax as succinct as possible for the 95% use-case

I hope that we'll one day have nested class definition, for the sake of sealed classes. And primary constructors would play a large role in the usability here

IMO there's a big difference between:

sealed class Entity {
  class City(final String name, {required final int population});
  class Person(final String name, {required final int age});
}

vs:

sealed class Entity {
  class City {
    City(String name, {required int population})
          : final String name,
            final int age;
  }

  class Person {
     Person(final String name, {required int age})
          : final String name,
            final int age;
  }
}

I'd personally prefer a syntax that's limited but effective. And suggest users to refactor to normal constructors if they need something more specific.

I assume a good IDE refactor would go a long way here.

cedvdb commented 6 months ago

I'd personally prefer a syntax that's limited but effective. And suggest users to refactor to normal constructors if they need something more specific.

Agreed, this one seems unnecessary:

class Foo { 
  Foo(int x): final int x = x; 
}

Hopefully it can also support private members:

class Foo { 
  Foo({ final int this._x }); 
}

final foo = Foo(x: 3);
rrousselGit commented 6 months ago

I'd stick to the syntax used by extension types personally.

extension type Example(/* primary constructor*/) {
   // Optionally define extra constructors if we wish to
   Example.name(/* named constructor */ ): this(...);
}

Just replace extension type with class, and we're done :D

I'd find it more confusing if classes deviated from extension types in that regard.
If extension types already have primary constructors, to me, all primary constructors should match that syntax. Be it for classes, extension types, or even enums.

In that sense, I personally really dislike how pattern matching introduced switch () { case value: vs switch () { value =>. I get why. But it trips people up. I've seen lots of beginners confused by the difference.