dart-lang / dart-lang/language

Functional/Expression macro

Open
#1,874 7 comments 44 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

feature static-metaprogramming
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.