Skip to content

Functional/Expression macro #1874

@rrousselGit

Description

@rrousselGit

There have been a few discussions related to this (such as jakemac53/macro_prototype#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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureProposed language feature that solves one or more problemsstatic-metaprogrammingIssues related to static metaprogramming

    Type

    No type

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions