-
Notifications
You must be signed in to change notification settings - Fork 209
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
Scoping augmentations down for code generators #4256
Comments
Hello there! Overall, as long as augmentations can apply @riverpod
class MyNotifier {} I'd like to generate: augment class MyNotifier extends Notifier<...> {
MyNotifier(super.ref, this.args);
final SomeType args;
} Currently, due to the lack of a way to add such constructor, I rely on a It's probably a tad off-topic, but I think there's one key feature that macro promised that we're loosing by "just" using augmentations: This is technically not an augmentation, but feels closely related. Specifically, with macros we could do: // We can pass functions in the annotation, and the generated code can use them
// We get breakpoints and this doesn't require a const function
@Annotation(someCallback: (someArg) => print('hello')) Currently this is a huge hassle with generators. The syntax is often instead: ReturnValue someFunction(Param someArg) {
print('hello');
}
@Annotation(someCallback: someFunction) This causes generators to pretty much give up on taking callbacks as parameters through the annotations and instead find different ways of achieving the same thing. |
Thanks Bob! Just one note to add re:
I would prefer to drop
--it was added to reduce the boilerplate you have to write, and arguably it makes sense with how codegen works today, but the Built interface is almost never actually used as an interface and the generics are very awkward indeed. The same goes for Builder. Just having those methods be normal methods on the post-generation type, with no interfaces, would be great :) |
Thanks, this is really helpful.
Hmm, that may have worked with the in-progress macro implementation, but I don't think it was ever fully intended to work. One of the reasons we canceled the feature is that we struggled for a very long time to pin down exactly what was allowed inside an annotation used for a macro application and what the semantics of those argument lists were. As far as I know, we plan to allow non-const expressions in there.
Yeah, in general passing a "chunk of code" to either a macro or a code generator is a hard problem. It immediately raises all of the hard questions around scoping/hygiene/variable capture that have plagued macros for decades. Those questions aren't unsolvable, but the solutions have a lot of tricky trade-offs. |
I think a lot of the challenges can be offloaded to the analyzer. One solution could be that given: @Annotation(cb: (arg) => print(arg)); Generators generate: void someRandomFunction() {
((arg) => print(arg))(value);
} With possibly some comments around it to tell Analyzer that this snippet was defined in the I've raised a few issues with a similar pattern yesterday for related topics:
Those could be ways to implement proposed Macro features without modifying the language. |
@munificent I’m not sure if you had a chance to already see the very interesting new project called Codable and its related RFC: New Serialization Protocol for Dart but I thought that you might also be interested in seeing how they were thinking about using Augmentations as outlined here: schultek/codable#10 |
Like in the example with JsonLiteral I faced something similar when I experimented with macros for Chopper. When defining a 'service' the abstract class with abstract method are annotated and the implementation is generated. But when I tried to augment the class and its method I also had to make them Currently it looks like this: @ChopperApi(baseUrl: '/resources')
abstract class MyService extends ChopperService {
static MyService create([ChopperClient? client]) => _$MyService(client);
@GET(path: '/{id}/')
Future<Response> getResource(@Path() String id);
@GET(path: '/list')
Future<Response<BuiltList<Resource>>> getBuiltListResources();
@GET(path: '/', headers: {'foo': 'bar'})
Future<Response<Resource>> getTypedResource();
@POST()
Future<Response<Resource>> newResource(
@Body() Resource resource, {
@Header() String? name,
});
} But if something like this could be possible with augmentations that would be really nice: @ChopperApi(baseUrl: '/resources')
class MyService {
@GET(path: '/{id}/')
Future<Response> getResource(@Path() String id);
@GET(path: '/list')
Future<Response<BuiltList<Resource>>> getBuiltListResources();
@GET(path: '/', headers: {'foo': 'bar'})
Future<Response<Resource>> getTypedResource();
@POST()
Future<Response<Resource>> newResource(
@Body() Resource resource, {
@Header() String? name,
});
} Without the need to add |
Going back to this: One unsolved problem by build_runner is how analysis contains One way generators can solve it is by relying on multiple part 'myfile.freezed.dart';
part 'myfile.g.dart'; This enables JSON-serializable to interact with types generated by Freezed. The problem with this solution is: It's super painful for users to have to add multiple parts. In that case: Could augmentation libraries import more augmentation libraries? Say we have: // file.dart
augment import 'file.freezed.dart'; Could we maybe support: // file.freezed.dart
augment import 'file.g.dart'; Then, the number of "phase" becomes an implementation detail rather than a requirement placed on users. |
Augmentation libraries are so 2024 ;) ... the design now is that augmentations can go anywhere. When you want to put them in a separate file, you'll use part files; and to make part files a better fit, there will be enhanced parts, which does indeed allow the nesting that you're asking for. Pretty much, enhanced parts solves the "generate to library or part?" question with a definite answer for all cases: "enhanced part" :) |
Cool! Then we'll have to make sure that it combines nicely with build_runner such that a generator can generate a part ; which is the asked to generate even more parts. Preferably, all that "runs_before" stuff in |
I'm optimistic ;) There is also some discussion about whether a generator could output a part for API and a nested part for implementation; then analysis can complete after the API is output without waiting for implementation. This gets interesting if a generator can output API more cheaply, for example if it can output API before resolution. |
By the way, did we settle on whether augmentations could add an I think that'd be quite useful. I can already see generators for sealed classes to add an automatic @freezed
sealed class Foo {
class A {}
class B{}
} augment class Foo {
augment class A extends Foo {}
augment class B extends Foo {}
} (obviously this is an example about a future feature, but I think that's a good one) And of course, there's the myriad of existing code-generators using @riverpod
class Counter extends _$Counter {...} |
I was not following these discussions, but this is a must IMO. Your last example is enough for me. I am also using a similar approach locally, where I have something similar. |
Consider a zig-style alternative: // the prototype
class _Counter {
// your class defs here
}
class Counter = @riverpod(_Counter);
class WithExtras = @extras(Counter);
// or directly:
class RiverpodWithExtras = @extras(@riverpod(_Counter)); (this doesn't require augmentations - every macro transforms its input into output, with no artificial restrictions). |
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:
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:
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:
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:
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: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:
When they do, the code generator produces a subclass:
Note how
anInt
is overridden by a getter/setter pair. Those internally callsuper.anInt
. If we supportaugmented
on getters/setters, thosesuper
calls can becomeaugmented
. But note also thesuper.anInt
call in_$this
. If_$ValueWithIntBuilder
is turned into an augmentation onValueWithIntBuilder
, 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:
Here,
_$this
no longer callssuper
to route around the setter. It just invokes the setter directly, which will go through the augmented setter which in turn callsaugmented
. 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:
Then the code generator reads that file and generates code in a part file like:
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:
Then the generated augmentation augments that variable by providing an initializer:
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?
The text was updated successfully, but these errors were encountered: