Skip to content

Scoping augmentations down for code generators #4256

Closed
@munificent

Description

@munificent

The augmentations proposal is very comprehensive. It basically lets you modify existing declarations in all possible ways. This was necessary because we wanted macros to be quite powerful and the intent was to have macro applications compile to a generated augmentation. With macros out of the picture now, we have the opportunity to simplify augmentations. We'd still like them to be powerful, but there are some corners of the proposal that always felt pretty subtle and tricky and if those corners aren't ultimately that useful, it's probably worth removing them.

Without macros, code generation will continue to be critical for the ecosystem. I think if code generators start outputting augmentations instead of just libraries or part files, the overall user experience can be significantly improved. For example, here's what a user of built_value has to write today:

abstract class SimpleValue implements Built<SimpleValue, SimpleValueBuilder> {
  static Serializer<SimpleValue> get serializer => _$simpleValueSerializer;

  int get anInt;
  String? get aString;

  factory SimpleValue([void Function(SimpleValueBuilder) updates]) =
      _$SimpleValue;
  SimpleValue._();
}

Here's what it could look like if built_value generated an augmentation instead of a part file with a subclass that the user has to explicitly forward to:

@serialized
class SimpleValue implements Built<SimpleValue, SimpleValueBuilder> {
  final int anInt;
  final String? aString;
}

To start to get a sense of what requirements code generators would place on augmentations, I looked at the built_value and json_serializable packages. (I know that @davidmorgan and @jakemac53 could have just told me the answer to these, but it was useful for me to load those packages into my head by doing the exercise myself.)

For both of those, I hand-migrated their examples to show what the user-authored and generated code might look like with augmentations:

Overall, the experience was a positive one. I think the hand-authored code gets smaller and simpler without the need to forward to the generated code. The generated code often gets simpler too. The set of augmentation features used was quite small. For the most part, it's just adding new top-level declarations and new members in classes.

I found a few interesting cases:

built_value field validation

The built_value package lets a user validate fields inside the value type's constructor, like:

abstract class ValidatedValue
    implements Built<ValidatedValue, ValidatedValueBuilder> {
  int get anInt;

  factory ValidatedValue([void Function(ValidatedValueBuilder) updates]) =
      _$ValidatedValue;

  ValidatedValue._() {
    if (anInt == 7) throw StateError('anInt may not be 7');
  }
}

This works because the code generator creates a subclass whose constructor ends up calling the superclass's constructor. I'd like it if the user didn't have to hand-write the forwarding constructor and let the generated augmentation define that, like so:

// User-authored:
abstract class ValidatedValue
    implements Built<ValidatedValue, ValidatedValueBuilder> {
  int get anInt;
}

// Generated:
augment class ValidatedValue {
  factory ValidatedValue([void Function(ValidatedValueBuilder)? updates]) =>
      (new ValidatedValueBuilder()..update(updates))._build();

  ValidatedValue._({required this.anInt});

  // ...
}

But then where does the validation code go? If we want to support constructor augmentations, then the user could define a constructor in the hand-authored class and then the generated constructor would call augmented to reach that constructor and run the validation in its body. But that requires the user to write the full parameter list for the constructor, which is fairly verbose. Instead, I think a cleaner approach is:

// User-authored:
abstract class ValidatedValue
    implements Built<ValidatedValue, ValidatedValueBuilder> {
  int get anInt;

  _validate() {
    if (anInt == 7) throw StateError('anInt may not be 7');
  }
}

// Generated:
augment class ValidatedValue {
  factory ValidatedValue([void Function(ValidatedValueBuilder)? updates]) =>
      (new ValidatedValueBuilder()..update(updates))._build();

  ValidatedValue._({required this.anInt}) {
    _validate();
  }

  // ...
}

The user writes a _validate() instance method. If the code generator sees that such a method exists, it inserts a call to it in the generated constructor in the augmentation. Since this method is called after the instance is created, it can be an instance method, so this works fine. This would also mean we don't need support for augmenting constructors.

built_value field wrappers in custom builders

The built_value package lets a user write their own builder:

 /// Builder class for [ValueWithInt].
abstract class ValueWithIntBuilder
    implements Builder<ValueWithInt, ValueWithIntBuilder> {
  int? anInt;

  factory ValueWithIntBuilder() = _$ValueWithIntBuilder;
  ValueWithIntBuilder._();
}

When they do, the code generator produces a subclass:

class _$ValueWithIntBuilder extends ValueWithIntBuilder {
  ValueWithInt? _$v;

  @override
  int? get anInt {
    _$this;
    return super.anInt;
  }

  @override
  set anInt(int? anInt) {
    _$this;
    super.anInt = anInt;
  }

  _$ValueWithIntBuilder() : super._();

  @override
  void replace(ValueWithInt other) {
    _$v = other;
  }

  ValueWithIntBuilder get _$this {
    final $v = _$v;
    if ($v != null) {
      super.anInt = $v.anInt;
      _$v = null;
    }
    return this;
  }

  ...
}

Note how anInt is overridden by a getter/setter pair. Those internally call super.anInt. If we support augmented on getters/setters, those super calls can become augmented. But note also the super.anInt call in _$this. If _$ValueWithIntBuilder is turned into an augmentation on ValueWithIntBuilder, then there's no way to express that.

You can't use augmented because you aren't in the member being augmented. This doesn't work even with the current full-featured augmentation spec.

In this case, I think the generator could instead produce:

augment class ValueWithIntBuilder {
  ValueWithInt? _$v;

  int? get anInt {
    _$this;
    return augmented;
  }

  set anInt(int? anInt) {
    _$this;
    augmented = anInt;
  }

  @override
  void replace(ValueWithInt other) {
    _$v = other;
  }

  ValueWithIntBuilder get _$this {
    if (_$v case final $v?) {
      // Clear it eagerly so that calling the setter doesn't infinite loop.
      _$v = null;
      // Call the setter, which will go through the augmentation.
      anInt = $v.anInt;
    }
    return this;
  }

  ...
}

Here, _$this no longer calls super to route around the setter. It just invokes the setter directly, which will go through the augmented setter which in turn calls augmented. To avoid an infinite loop, we clear _$v first.

This was the only place in the two packages where I found the need to use augmented.

JSON literals in json_serializable

The json_serializable package supports reading in a separate data file of JSON and inserted it into the generated code as static data. If a user writes:

@JsonLiteral('data.json')
Map get glossaryData => _$glossaryDataJsonLiteral;

Then the code generator reads that file and generates code in a part file like:

final _$glossaryDataJsonLiteral = {
  // Big blob of JSON...
};

With augmentations, it would be nice if the user didn't have to write an explicit forwarder like _$glossaryDataJsonLiteral. But they need to write something to associate a name with a literal and hang the @JsonLiteral metadata annotation of it.

It can't be an abstract getter because it's a top-level declaration. It could be an external getter, though that feels a little misleading to me since it's not really external to the program.

Instead, I suggest that it be a variable declaration with no initializer:

@JsonLiteral('data.json')
final Map glossaryData;

Then the generated augmentation augments that variable by providing an initializer:

augment final Map glossaryData = {
  // Big blob of JSON...
};

This requires support for augmenting a variable declaration with an initializer. It doesn't need to be able to call the original declaration's initializer (since there is none).

Summary

These are the only packages I've looked at so far. I started poking at freezed, but it's pretty big and will take me a while to dig into. (If you have thoughts on how you'd like to use augmentations in freezed, @rrousselGit, I would definitely like to hear them.) If there are other widely used code generated packages, I'd like to hear about them so I can see what kind of requirements they would place on augmentations.

So far, from these two packages, what I see we need is:

  • Adding new top-level declarations.

  • Adding new members to existing classes. All kinds of members: instance, static, fields, methods, etc.

  • In one place, augmenting a variable with an initializer. We could take other approaches if this was problematic.

  • Calling augmented in an augmenting getter and setter to access the augmented field. If necessary, we could probably tweak the design to avoid this as well, though it would be trickier.

  • Potentially calling an augmented constructor body from an augmenting constructor. I worked around it by using _validate() instead which I think is also a better user experience.

Thoughts? Any other packages I should look at?

Metadata

Metadata

Assignees

No one assigned

    Labels

    augmentationsIssues related to the augmentations proposal.

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions