-
Notifications
You must be signed in to change notification settings - Fork 205
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
File based metaprogramming #1864
Comments
That sounds interesting. Am I correct in thinking that this proposal is complementary to https://github.com/jakemac53/macro_prototype? As in this describes a low level API, and the prototype describes a simplified API Or is this a separate proposal with different constraints?
I'm sure there's a good reason for this, but what is it? |
I'd say this is basically a strawman, so that I can ask "how is this-or-that better than just using a file?" :) For the required |
I see thanks! As a potential challenge to this approach, how would we deal with expression macros? jakemac53/macro_prototype#29 fn() {
final value = @macro(expression);
final value2 = @macro(expression2);
} But with a file based approach, and especially the "a file cannot be changed after addition" constraints, I don't see an obvious way to update the function In particular, I wish to be able to define: @provider
State another(...) {}
@provider
Again oneMoreTime(...) {}
@provider
int $count(CountRef ref) {
State value = @watch(another);
num value2 = @watch(another.select((Again a) => a.number));
@listen<State>(another, (State value) {
print('another changed $value');
});
} and at compilation transform it into: @provider
int $count(CountRef ref) {
State value = ref.another.watch();
num value2 = ref.oneMoreTime.select((Again a) => a.number));
ref.another.listeners1 = (State value) {
print('another changed $value');
};
}
// generated
final count = Provider((ref) => $count(ref));
class CountRef {
get another;
get oneMoreTime;
...
} But it's not obvious how |
Using Having the macro implementation code exist on the same object use as annotation means that that separation becomes much harder. We'll need special rules to recognize macro annotations and treat them differently from other annotations. My approach does not treat them differently, they're just normal annotations. They get recognized by the tool (the compiler), but that's what's annotations are for. I also try to define which environment the macro runs in, without inventing a completely new kind of execution mode. It runs as a fresh isolate, same as every other isolate. You can test it by simply importing the library and calling As for user experience, possibly not that much difference. The goal was to actually specify a consistent model for source and execution, keep it simple (no need to add lots of new language features just to support it, the ones suggested are generally useful features), keep it unrestricted (don't restrict the code you run in the macros, don't try to prevent errors - just detect them). And no plans for expression macros. I am wondering if there is a way to apply a "mixin"/wrapper to other declarations, which is again something which could be generally useful. |
Would it be possible to detect an annotation is a macro because it extends the |
Since macro detection happens at compile-time, we can do whatever the compiler can do. For a constant object, it can certainly check whether the annotation implements a specific It can even check whether the runtime type extends the Heck, we could even make the macro trigger class Macro extends pragma {
Macro(...) : super("macro", {...});
} as a shorthand. The compiler can figure that out. That |
This is a really interesting strawman and I'm glad we're exploring how we could make static metaprogramming simpler and more easily implementable. I'm definitely interested in some kind of layered approach where static metaprogramming is a combination of:
I like what you have here for 1 (the easy part), but I'm not sold on 2. A couple of specific concerns: Introspecting on incomplete libraries
This touches on one of the core challenges. Before static metaprogramming has run, you have some human-authored code that may refer to code that hasn't been generated yet. How do you handle it gracefully? Here, you only talk about incomplete code in the macro annotations themselves, but macros also need to introspect over the in-progress program and the results of that introspection also need to be well-defined. You briefly mention:
But I think this is pretty hard and probably needs more attention. Consider: import 'generated_by_macro.dart';
class A {
var foo = 'field';
var bar = foo;
} Here, it is possible to infer var foo = 123; So now the previous preliminary type inference has changed. We might be able to extend what you said above and say it's a compile-time error if a macro generates code that changes the resolution of any identifiers, but that's probably too brittle too. A user might intend for the macro to generate code that shadows some other code. Or they might get themselves into that state by what should be a non-breaking refactoring, but would become breaking if the macro system conservatively makes it an error. Macro application syntaxThe goal with static metaprogramming isn't to allow users to completely seamlessly extend the Dart language in new and unforeseen ways, but we do want uses of it to look fairly graceful. My bellwether for doing this feature right is if users can use it to define data classes using macros without needing built-in language support. Given that data classes are a native feature in Kotlin, our benchmark is pretty high. I don't think users would think we "solved" data classes if they had to write: // my_library.dart:
@Data("my_library_some_data_type.g.dart")
class SomeDataType { ... }
@Data("my_library_another_thing.g.dart")
class AnotherThing { ... } In particular, requiring a unique URL in every macro application means macro authors can't provide simple "copy and paste this into your app" examples on how to use the macro. This just looks like exposing too much plumbing to me, but maybe I'm missing something. |
@munificent One solution, which has been discussed before during the conditional imports design It's also very dangerous to say that a generated file will somehow have lower precedence than an implicit Another solution is to treat unqualified identifiers that are not in the lexical scope as unknown as long as a library has any missing parts (which includes any imported libraries having missing parts). The unknown is what we would already do with unresolved identifiers that may be filled in by a later generated file, similarly to import "c.dart" show C;
class A {
final num foo;
A(this.foo);
}
class B extends C /* which extends A */ {
var bar = foo;
} Then For the macro application syntax, I think we can do things to help it, like allowing it to request all annotations of the same macro in one library to be passed to the same macro execution. That would allow it to have a default name, so you could just write: // my_library.dart
part "my_library.data.g.dart"; // or whatever the default would be.
@Data()
class SomeDataType { ... }
@Data()
class AnotherThing { ... } Then the data-class macro would be invoked with both annotations (because the So, I think that's something that can be addressed, while also being usable. |
Would it be simpler if the macro was called once per annotation, and all the outputs of macro annotations within the same file are collected by the compiler and put into the same file? So you'd have |
"Collecting" source sounds tricky. I guess you can collect all the I'd rather tell the macro code how much it needs to generate, then let it create the one file (or more files, if necessary). If it needs parallelism, it can spawn its own isolates as needed. |
This is the sticking point. In most of the use cases we have, the macro doesn't just need to see the class definition as a raw AST (pure syntax), it needs to see it in actually resolved form so that it can introspect over the types of fields and methods, walk the superclass hierarchy, etc. |
This looks obsolete, if not please reopen. Thanks! |
This is a (sketch/draft/initial) proposal for an approach to meta-programming/macros which puts very few restrictions on the code you can use to write macros (no circularity, basically) and focuses on ensuring that the result of compilation can always be represented by a consistent set of source files.
Dart File-Based Macros
Author: [email protected]
Version: 1.0
Goals
This is a proposal for a fully-featured and general meta programming functionality for Dart. The feature supports generating program code at compile- or analysis-time in a consistent and predictable way, and doing so using plain idiomatic Dart code.
The generated code will be inspectable during development and it will be possible to use the same meta-programming framework generate the code ahead-of-time and distribute the generated code instead of the generator.
Fundamentals
A Dart program is compiler, or analyzed, in a compilation environment.
A Dart program is defined by a set of libraries, which are loaded from a consistent set of files. Each library has a URL (possibly more if it contains parts), and the mapping from URL to source file is uniquely and consistently defined at compile-time by the compilation environment.
Further, the compilation environment defines the mapping from
package:
URLs to source files, the availability ofdart:
-URL libraries, and may define certain "environment declarations" or flags which are available to the compilation process.The most central requirement here is that this environment is consistent across the entire program. Accessing the same file more than once must always yield the same source code, and accessing the same environment declaration more than once must always give the same value (if any).
Meta-programming works by adding files to the compilation environment during compilation. A file can be added at most once, and it cannot be changed after it has been added. Source files may reference missing files, and the program is considered incomplete until all required source files have been provided.
This requires some tools to be able to handle partial, incomplete programs until the program has been completed. At least the compiler needs to be able to detect, trigger and run the macros that should generate the missing files.
Language extensions
Top support this feature, the language also needs:
Partial class/mixin/extension declarations. Multiple declarations of the same class/mixin/extension are allowed in the same library, as long as at most one is not marked
partial
(orpart
to reuse a built-in identifier). The same member can be declared in multiple parts, as long as at most one is non-abstract and non-external (and exactly one if the class is not abstract).Needs the
part
prefix in order to omit otherwise required parts of the declaration. Part classes cannot apply mixins, only members (because if two part classes both apply mixins, they have no natural application order).Part files with local
import
andpart
directives (so generated files can trigger more generated files).Macros
A macro is a Dart script. That is, a Dart library with a
main
method. The macro script is triggered by the compiler when encountering a special annotation in the program being compiled. The annotation includes the URL of the script to be run.The compiler then compiles that script in the same compilation environment as the annotated program. It is, as usual, an error if the macro script cannot be compiled for any reason, including missing source files which haven't been generated yet. The macro script, or the libraries it depends on, can contain other macro annotations, and trigger other macros to be compiled and run.
It's a compile-time error if a macro annotation with a URL denoting the same macro script is encountered during compilation of the macro script (which includes all the libraries transitively imported by the script library, and the libraries of all macros transitively used by the macro script itself). That is, you cannot have a cyclic dependency between macro scripts, with macro annotations being a new kind of dependency.
When the macro script has been compiled, it is run. The script's
main
method is invoked with a list containing a singleString
argument pointing out the annotation which originally triggered it, which is a string representation of the source URL containing the annotation followed by a fragment pointing to the position, like#offset=4123&line=124&column=2
, and the second argument is a "macro context" object. The type of this object is defined indart:macros
as an abstract interface. The implementation is implicitly included in the macro script compilation. (Providing the macro context as an argument makes it possible to mock it for testing, instead of, say, getting it asMacros.currentContext
.)The goal of running a macro is to provide one or more missing files to the compilation environment. It's technically possible to not generate any code at all, and just act as an extra validation step, but most macros will want to actually do something.
The macro context object provides functionality to:
sendAndExit
feature to report back to the compiler).We can provide helper libraries for analyzing source files, for generating source files, but in the end, the macro just writes a file. They can generate the content in whatever way they want, as long as they write it back through the macro context.
Example macro:
If a macro script ends (isolate terminates) without it calling
context.close
, then it's assumed to have failed. That causes a compile-time error, and none of its files become visible to other compilation phases.If the macro calls
close
without writing any files, it causes a warning to be printed, unless it passesnoFilesWritten: true
to say that it's deliberate.Parallelism
The compiler tries to parallelize macros as much as possible. Macros are run in turns, in topological dependency order. When a program has been read as far as possible given the available source files, than all its macro annotations are found.
Then each of these macros are compiled as well, transitively (if necessary, if the macro has been seen before, it might already be ready to run).
Then the compiler checks if this compilation has added any files needed by the current program (possible, but unlikely, and probably something we should warn about if it happens. If there are no cyclic dependencies between scripts, then it's odd that a file needed by the current program is also needed by something it depends on).
Then all the macro annotations are processed by running their associated macro. These macros only see the files as they existed before this step. They can, and will, run in parallel. If two macros try to write the same file, it causes a compile-time error when they have both closed.
After this step (or during, as soon as files are available), the compiler parses the newly generated files that are imported by the existing program, and incorporated them into the program. If they contain new macro annotations, those are scheduled for the next step.
When the first turn is completely done, the next turn starts running all new annotations added in the files generated by the first turn. And so on. (Should we start earlier, immediately when one macro completes, scan its files, add just those to the available files for its new macro invocations?)
Algorithm
To compile a script:
Macro
fromdart:macros
(which has a constructor taking aString
as argument which is the URI of the macro script). Resolve and create those constants, if possible. If any argument contains an unresolved identifier, delay trying to resolve it until the next iteration.To run a macro:
MacroContext
fromdart:macros
(which can be a thin wrapper around calls into to the compiler), then call themain
of the macro script with the location of the annotation to process and the macro context implementation.context.file(...)
or similar API.context.close
, the macro shuts down and commits the files written so far to the compiler.context.close
, then it's a compile-time error. The isolate treats uncaught errors as fatal by default.context.keepAlive()
occasionally to tell the macro system that it knows that it's slow and it wants more time.Reusable macros
The API allows a list of locations to be passed to the
main
script of a macro. A macro annotation might be able to request handling multiple annotations, if so, if a step contains more than one occurrence of the same macro, and that macro has passed a number larger than one to theMacro
constructor's optional{int maxTasks = 1}
parameter, it can gets than one annotation URL pass to it.The Macro Annotation
Example macro annotation:
The text was updated successfully, but these errors were encountered: