dart-lang / dart-lang/language
Functional/Expression macro
Nobody has claimed this yet.
- Dominant language
- TeX
- Stars
- 2.9k
- Forks
- 239
- Avg merge
- 2d 18h
- Merged PRs (30d)
- 14
Description
There have been a few discussions related to this (such as https://github.com/jakemac53/macro_prototype/issues/29), but I don't think there's a problem language issue for it yet, so here it is
Long story short, the idea for this proposal is to allow metaprogramming to define macros that are applied on expressions, such users can write:
void function() {
final value = @macro(params);
}
And the macros would be able to replace the final value = @macro(params); code (but not what is before and after this code) with a modified expression.
This could enable a variety of useful patterns, such as:
Implementing an await-like keyword for Widget builders
A common issue with Flutter widgets is that they tend to get rapidly nested:
Widget build(context) {
return StreamBuilder<A>(
stream: aStream,
builder: (context, AsyncSnasphot<A> aSnapshot) {
return ValueListenableBuilder<B>(
animation: bListenable,
builder: (context, B b, _) => Text('${a.data} $b),
);
}
);
}
This is an issue similar to Future.then, which the language solves with await, but Widgets have no equivalent.
With expression macros, we could instead write:
Widget build(context) {
AsyncSnapshot<A> aSnapshot = @StreamMacro(aStream);
B b = @ValueListenableMacro(bListenable);
return Text('${a.data} $b);
}
and the macros would desugar this code into the previous code
Stateful functional widgets
Through expression macros, it could be possible to drastically simplify stateful widgets by offering a @state macro, typically combined with a @functionalWidget macro, such that we'd define:
@functionalWidget
Widget $MyWidget(_MyWidgetState state) {
var count = @state(0);
return Column(
children: [
Text('Clicked $count times'),
ElevatedButton(onTap: () => count++),
]
);
}
And this would desugar var count = @state(0) into:
int get count => state.count;
set count(int value) => state.count++;
such that full final code would be:
@functionalWidget
Widget $MyWidget(_MyWidgetState state) {
int get count => state.count;
set count(int value) => state.count++;
return Column(
children: [
Text('Clicked $count times'),
ElevatedButton(onTap: () => count++),
]
);
}
class MyWidget extends StatefulWidget {
const MyWidget({ Key? key }) : super(key: key);
@override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
int _count = 0;
int get count => _count;
set count(int value) {
setState(() => _count = value);
}
@override
Widget build(BuildContext context) {
return $MyWidget(this);
}
}
(this assumes that we can define local gettters/setters, but I think that's a reasonable request)
Guards
A common thing when dealing with null or functional classes like Result/Either is to do:
Class? function() {
final a = getA();
if (a == null) return null;
final b = getB();
if (b == null) return null;
<some logic>
}
or:
Result<Class> function() {
Result<A> a = getA();
if (a.isError) return Result<Class>.errorFrom(a);
Result<B> b = getB();
if (b.isError) return Result<Class>.errorFrom(b);
<some logic>
}
...
This could be simplified by a @guard macro, such that we would define:
Class? function() {
final a = @guard(getA());
final b = @guard(getB());
<some logic>
}
and this would generate the if (x == null) return null / if (x.isError) return Result<T>.errorFrom(x) for us.
Custom compiler optimisations
Rather than simplifying existing code, an alternate use-case would be to make existing code more performant.
A use-case I personally have is with Riverpod, where users can do:
const provider = Provider<A>(..);
const anotherProvider = Provider<B>(...)
<...>
class Example extends ConsumerWidget {
const Example({ Key? key }) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
A a = ref.watch(provider);
B b = ref.watch(another);
...
}
}
where ref.watch works by doing a Map.putIfAbsent, such that the previous ref.watch usages are roughly equal to:
@override
Widget build(BuildContext context, WidgetRef ref) {
A a = ref.listeners.putIfAbsent(provider, () => provider.listen(...));
B b = ref.listeners.putIfAbsent(another, () => provider.listen(...));
...
}
This works fine, but it would be better if we could remove this Map. With expression macros, we could possibly implement a @watch, such that we'd have:
@override
Widget build(BuildContext context, ExampleRef ref) {
A a = @watch(provider);
B b = @watch(another);
...
}
and this would equal:
@override
Widget build(BuildContext context, ExampleRef ref) {
A a = ref.providerListener ??= provider.listen(...);
B b = ref.anotherListener ??= another.listen(...);
...
}
abstract class ExampleRef {
Subscription? providerListener;
Subscription ? anotherListener;
}
effectively removing the Map in the process by exploding it into class properties
Dependency tracking without AST parsing
Using the same code as the previous example, Riverpod would like to generate a static list of dependencies associated with a "provider".
The idea is that with:
final a = Provider<A>(...);
final b = Provider<B>(...);
final example = Provider<Example>((ref) {
A a = ref.watch(a);
String str = ref.watch(b.select((B b) => b.str));
return <...>
}, dependencies: {a, b});
we would like macros to generate the dependencies parameter of example. The problem being, existing macro proposals currently do not allow macros to access the AST. Which means something like:
@autoDependencies
final example = Provider<Example>((ref) {
...
});
would not be able to search for ref.watch invocations to generate that dependencies list.
But if we replaced ref.watch with a @watch macro, we could possibly have that @watch do the generation, such that individual @watch usage would all declare their parameter in the dependencies list
Note:
I am aware that this use-case would likely require subtle changes to the Provider usage to work. Because macros likely wouldn't be able to push items into a collection. The important is being able to statically extra calls to ref.watch within the function. From there, the Provider definition could change to match the requirements.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
The proposal does not identify implementation files, tests, or entry points. Start by reviewing the linked macro discussion and the existing macro proposals, then determine the language and compiler design needed before defining what a complete implementation would include.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart
- Domain
- compilers
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100