-
Notifications
You must be signed in to change notification settings - Fork 213
static enough metaprogramming #4271
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
Comments
I wonder what the performance overhead would be compared to what you could achieve using code-generation. Is it safe to assume that raw Dart code would still be faster ; due to:
|
At first glance, this sounds like a really cool idea. I'm curious to play around with it more and try to understand its uses and limitations.
I'm very interested in looking at this! Did you forget to paste in the link? |
(Working my way through the proposal, so I might not have all the details yet.) The example: void bar<@konst T>(@konst T v) { }
for (@konst final v in [1, '2', [3]]) {
invoke(bar, [v], types: [typeOf(v)]);
} doesn't vibe with the
goal. If the compiler doesn't support The reason you need the But then it won't be able to have the same semantics if executed at runtime if the compiler doesn't do This is a two-level language, like Scheme's quote/unquote, but it's omitting the quote on the list elements and the unquote in the body of the (The substitution into loops is also worrisome, take {
var i$0 = expr1;
var i$1 = expr2;
...
var i$N = exprN;
{
body[i$0];
}
// ...
{
body[i$N];
} What if we had void bar<@konst T>(@konst T v) { }
for (@konst final v in [quote(1), quote('2'), quote([3])]) {
v.unquote(<T>(o) => bar<T>(o));
} Still would't work the same if executed at runtime. Would be: @konst
class Expr<T> {
final T value;
Expr(@konst this.value);
@konst
R unquote<R>(R Function<X>(X value) use) => use(value);
}
Expr<T> quote<T>(T value) => Expr<T>(value); (Or just call the class |
I worry about the field-reflection because it doesn't say what a "field" is. If it's any getter, then this is probably fine. If it distinguishes instance variables from getters, then it's almost certainly not fine. That'd be breaking the abstraction of the class, and of its superclasses. Which also means that if you do: for (@konst final field in TypeInfo.of<T>().fields)
if (!field.isStatic) field.name: field.getFrom(value) you'll probably also need a (Can you see private fields? If you are in the same library?) |
Can you fix the link? |
FWIW: the review would be more complete if you also consider Julia's metaprogramming as a source of ideas. |
Wouldn't it be simpler to simply expand access to const, and (less simply) just give access to reflection there and only there? // figure out how to mark this as constant in all ways.
external T constInvoke<const F extends Function>(F fn, posParams, namedParams, {List<Type> typeArguments}) const;
const Object? toJson<T>(T object) {...}
// could be a modifier like async
Object? toJson<T>(T object) const {
if ((T).annotations.hasCustomToJson) {
// ...
}
return switch (object) {
null || String() || num() || bool() => object,
_ => toJsonMap(object),
};
}
Map<String, Object?> toJsonMap<T>(T object) const {
return <String, Object?>{
// probably break this out more to check for annotations and such.
for (final field in (T).fields) field.name: constInvoke(toJson, [field.of(object)], typeArguments: [field.type])
};
} and possibly have after all, there are often classes (read: annotations) that arent ever supposed to not be const. |
Wouldn't it be an option to allow exactly that, instead of adding the "wonky" invoke function? So void bar<@konst T>(@konst T v) { }
for (@konst final v in [1, '2', [3]]) {
final t = typeOf(v);
bar<t>(v);
} would be allowed since the generic parameter is a compile time constant? |
void bar<@konst T>(@konst T v) { }
for (@konst final v in [1, '2', [3]]) {
final t = typeOf(v);
bar<t>(v);
} While reading this program, I cannot easily see that the comptime for (final v in [1, '2', [3]]) {
final t = typeOf(v);
bar<t>(v);
}
// more general form:
comptime {
for (final v in [1, '2', [3]]) {
final t = typeOf(v);
bar<t>(v);
}
// other stuff executed in comptime
//...
} |
I think the link should have been: https://github.com/mraleph/sdk/tree/static_enough_reflection. At least that branch matches this proposal and have a recent commit related to this topic: mraleph/sdk@d594629 |
RE: @rrousselGit
The performance overhead at which stage and at which mode? If we are talking about runtime performance for the code compiled with toolchain which supports
@stereotype441 @aam I have fixed the link now. It was included but I marked it incorrectly in the references section. Don't look to much at the prototype though - it is very hodgepodge. I am implemented just enough to get my samples running. RE: @lrhn
That's not what the proposal proposes. Please see above (emphasis added):
So no expression business - you substitute variable with a constant value. This guarantees the following property: if I do have an interest in AST based metaprogramming, but this proposal is explicitly not about it, because you can't avoid expanding AST templates. Regarding the second comment about fields vs getters.
RE: @TekExplorer
I think you are missing one crucial piece: These functions are partially2 constant - if some arguments (e.g. specifically That being said there is actually a way to make this model work, but it is not going to be pretty. You need to manually split constant and non-constant part of the typedef JsonMap = Map<String, Object?>;
/// Make serializer for T - this is constant part.
JsonMap Function(T) toJsonImpl<T>() const {
final write = (JsonMap map, T o) {};
for (final field in T.fields) {
final previous = write;
write = (JsonMap map, T o) {
previous(map, o);
map[field.name] = field.getFrom(o);
};
}
return (o) {
final m = <String, Object?>{};
writer(m, o);
return m;
};
}
class A {
JsonMap toJson() => (const toJsonImpl<A>())(this);
} But this has a bunch of draw-backs:
RE: @schultek I should rewrite the section about I think Another reason to add Footnotes
|
Ack, I did misunderstand how konst-loops work. They are only over constant values (or at least "konstant values"), in which case duplicating the value is not an issue.
It's definitely possible to allow reflection only on instance variables, and not getters, but it means the code using reflection may change behavior, or even break, if someone changes a variable to a getter or vice versa, or adds implementation details like an I'll absolutely insist that the breaking change policy says that reflection is not supported by any platform class unless it's explicitly stated as being supported. If someone complains that their code breaks in Dart 3.9.0 because I changed Also means that you can't reflect anything if all you have is an abstract interface type. It's all getters. Accessing inherited fields explicitly through a
I don't believe "current library" is a viable or useful distinction. Map<String, Object?> getAllFields<@konst T>(@konst List<FieldInfo<T, Object?>> fields, T receiver) =>
{for (@konst f in fields) f.name: f.getFrom(receiver)}; If there are some fields you can only access from "the same library", this helper function won't work. If the implementation performs reflection at runtime, it would implement I would at least make it possible to not get private members, and having to opt in to it. (But if a class needs to opt in to being reflectable to begin with, they could choose which kind of reflectable to allow, and maybe even for which individual fields. As long as the default So, the crux is that anything marked with It's not a languge feature because the same code can be evaluated at runtime. It just requires some reflective functionality from the runtime system, but which can still only be invoked with values that could be known at compile-time. |
Still don't understand why you need to use weird @K-words in Map<String, Object?> getAllFields<@konst T>(@konst List<FieldInfo<T, Object?>> fields, T receiver) =>
{for (@konst f in fields) f.name: f.getFrom(receiver)}; where you could write quite legibly comptime Map<String, Object?> getAllFields<T>(List<FieldInfo<T, Object?>> fields, T receiver) =>
{for (f in fields) f.name: f.getFrom(receiver)}; Also: in zig, the variable declared as The concept of comptime was formalized in zig after years of bikeshedding. If you try to borrow just some parts of it, you may eventually realize why you needed other parts, too 😄 |
I think this is fine. We can certainly change the definition of the breaking change to accommodate this. Note that changes which you describe do already break programs which use
You can get methods (including getters), but not fields from such type. I think that's okay.
Not everything is accessible through mirrors - e.g. we do restrict access to private members of
Yep, I think that's precisely a feature I propose. Except "all arguments?" part. Only @tatumizer
You should not overindex on
|
A few interesting examples imo:
Example: Future<List<LibrarySectionStatistics>> findUnresolvedIssuesGroupedBySectionOrderedByIssueCount() async {
return await _libraryDbContext.bookIssues
.asNoTracking()
.where((bookIssue) =>
bookIssue.status == BookIssueStatus.pendingOrInProgress &&
bookIssue.resolutionDate == null &&
bookIssue.section != null &&
bookIssue.bookId != null)
.groupBy((bookIssue) => bookIssue.section)
.map((sectionAndBookIssues) => LibrarySectionStatistics(
section: sectionAndBookIssues.key,
totalIssueOccurrenceCount: sectionAndBookIssues
.sum((bookIssue) => bookIssue.occurrenceCount),
uniqueIssueOccurrenceCount: sectionAndBookIssues.length,
))
.orderByDescending((result) => result.uniqueIssueOccurrenceCount)
.toList();
} would generate the translated SQL: SELECT
Section AS Section,
SUM(OccurrenceCount) AS TotalIssueOccurrenceCount,
COUNT(*) AS UniqueIssueOccurrenceCount
FROM
BookIssues
WHERE
Status = 'PendingOrInProgress'
AND ResolutionDate IS NULL
AND Section IS NOT NULL
AND BookId IS NOT NULL
GROUP BY
Section
ORDER BY
COUNT(*) DESC; and generate the code to map the results back to objects. |
@Wdestroier Your Dart code is an order of magnitude less readable than SQL code. Which makes me doubt this sort of use case is something I would want to care about... I think it is questionable API design if you want That being said. I think it is a valid question if we eventually want to support some form of expression trees or way to interact with AST from Example of using custom marker objects to extract expression treessealed class ColumnExpression {
ColumnExpression operator +(Object other) {
final rhs = switch (other) {
final int v => ConstantValue(v),
final double v => ConstantValue(v),
final ColumnExpression e => e,
_ => throw ArgumentError('other should be num or ColumnExpression'),
};
return BinaryOperation('+', this, rhs);
}
ColumnExpression operator /(Object other) {
final rhs = switch (other) {
final int v => ConstantValue(v),
final double v => ConstantValue(v),
final String v => ConstantValue(v),
final ColumnExpression e => e,
_ => throw ArgumentError('other should be num, String or ColumnExpression'),
};
return BinaryOperation('/', this, rhs);
}
String toSql();
}
ColumnExpression sqrt(ColumnExpression expr) => UnaryOperation('SQRT', expr);
final class ConstantValue<T> extends ColumnExpression {
final T value;
ConstantValue(this.value);
String toSql() => '$value';
}
final class ColumnReference extends ColumnExpression {
final String ref;
ColumnReference(this.ref);
String toSql() => '$ref';
}
final class BinaryOperation extends ColumnExpression {
final String op;
final ColumnExpression lhs;
final ColumnExpression rhs;
BinaryOperation(this.op, this.lhs, this.rhs);
String toSql() => '(${lhs.toSql()} $op ${rhs.toSql()})';
}
final class UnaryOperation extends ColumnExpression {
final String op;
final ColumnExpression lhs;
UnaryOperation(this.op, this.lhs);
String toSql() => '$op(${lhs.toSql()})';
}
class BookIssuesColumns {
const BookIssuesColumns();
ColumnExpression get year => ColumnReference('Year');
ColumnExpression get numberOfPages => ColumnReference('NumberOfPages');
}
void main() {
print(((bookIssue) => sqrt(bookIssue.year)/bookIssue.numberOfPages + 1)(const BookIssuesColumns()).toSql());
// SQRT(Year)/NumberOfPages + 1
} |
I prefer Dart's syntax (operations are not out of order), but I have other arguments, for example: with an ORM the database can be changed from Postgres to MongoDB without rewriting all the SQL code, because changing the provider would be enough. Perhaps this code is more readable: Abbreviated codeFuture<List<LibrarySectionStatistics>> findUnresolvedIssuesGroupedBySectionOrderedByIssueCount() async {
return await _libraryDbContext.bookIssues.asNoTracking()
.where((b) =>
b.status == BookIssueStatus.pendingOrInProgress &&
b.resolutionDate == null &&
b.section != null &&
b.bookId != null)
.groupBy((b) => b.section)
.map((g) => LibrarySectionStatistics(
section: g.key,
totalIssueOccurrenceCount: g.sum((b) => b.occurrenceCount),
uniqueIssueOccurrenceCount: sectionAndBookIssues.length,
))
.orderByDescending((r) => r.uniqueIssueOccurrenceCount)
.toList();
}
Apparently I feel like writing
True, I agree. I gave a very difficult example I could think of 😄. Creating something equivalent to EntityFramework would probably require effort from the Dart team, it is very hard to implement without special language or compiler features imo. |
@Dangling-Feet you don't seem to be providing feedback for this particular proposal and seem to instead propose macro system similar to one which was already explored and shelved for a variety of reasons. Such generic comment is better posted to #1482 |
@mraleph Thank you. |
Waiting good news ;) (We need something like this which helps create toJson/fromJson, (ORM?), data classes, configs and etc without wasting time for generation) GL! |
I might be way too ignorant to comment this issue, but reading this scares me:
This is really scary.
I don't think so. Again I'm not smart enough to have a strong opinion on this, but I feel like this is a deal breaker for me. I think of my clients and I just can't afford the consequences of this. I don't know, maybe I'm misreading these two sentences. But if I read these correctly, wouldn't it be possible to avoid this problem, somehow? Can't the trade-off be put somewhere else? 🥺 |
class Foo {
final int _bar;
this({ required int bar}) : _bar = bar;
} So you'd have to check whether the name starts with
@lucavenir It wouldn't go into production, because it wouldn't compile. |
In the same regards, serialization is such a rabbit hole - you can get stuck there for life. Here's the list of annotations to control serialization in a popular jackson framework. This list is incomplete (stuff gets added all the time), and it cannot be made complete in principle because of its infinite size. |
Oh damn, you're right. Woops! Still, my cortisol levels aren't lowering; the "works on my machine" issue becomes a build time issue, which can potentially back-propagate to my codebase. And this can potentially black-swan my software production's lifecycle. Did I get this right? Let me imagine a scenario. For example I could potentially write my application, test it, use it in debug mode (JIT compiler), be happy with it. I fear this is still a deal-breaker for me. I'm not convinced this is a "small price to pay", yet. |
How's that different from today?
You control the buttons you press |
It's different because as of today the average developer won't shoot its foot like that; AFAIK the only way to set-up a footgun like this, today, is to use There are "general purpose" VM-related annotations, but again no one really uses them in their day-by-day code, isn't it? Also these apply to the VM and not to the AOC compiler AFAIK. The point is - this proposal kind-of suggests that
Sure. Until you don't. |
I think all compilers should enforce the const-ness requirements of That probably means that the What is important is that a I guess it is still possible to get a runtime-error due to the the reflected data, things that can't be checked by static analysis alone, if you make assumptions like Basically, the gurantee is that if
Then there should also be no issue with the code compiling and running successfully in development, but failing to compile with a production compiler. The development compilers would have failed to compile or to runt the code too, if the program contains anything that the production compiler would fail at. |
Note that today it is possible (for many of our compilers and the analyzer) to compile/analyze a library given only the API level information of its dependencies (ie: the analyzer summary or kernel outline). If any library can invoke any dependencies code at compile time that is no longer the case, and this has significant implications for invalidation, especially in scenarios such as blaze. This is exactly why we previously tabled the enhanced const feature. We could alleviate these concerns potentially by separating out the imports into const and non-const imports (via some syntax), and in the blaze world we would probably want this to correspond to const and non-const deps in the blaze rules. This would allow the build systems to know which dependencies need to be included as full dill files (and for the analyzer, probably just as Dart source files), instead of summaries, so that the cost can be paid only for the dependencies that are actually used at compile time. This wouldn't be a perfect solution though because all transitive dependencies of those dependencies would also have to be included as full dills, since we don't know what will actually be used. |
I agree that perfection is the enemy of the good. We should wait for the revised version of his proposal before complaining about potential issues. I trust that compilation errors are very unlikely and won't significantly hinder the developer experience. Also, don't forget that most people will never directly use |
For sure!
Here's the thing: Do we even need build_runner already exists. And some packages that use build_runner enable reflection-like usage. Cf reflectable This could enable code like: Map encode<T static extends Reflectable>(T value) => {
for (final field in T.fields) ...
}; Used as: @reflectable
class Person {}
encode(Person());
// Generated
augment class Person static implements Reflectable {
static const fields = [...];
} This would be much more tree-shakable than what reflectable currently offers. Now, we'd suffer from runtime errors still. But that feels inherent to reflection. The thing is that this implementation doesn't really require anything big from a language PoV (#4200 sounds much more trivial than this issue, and is useful on its own rights regardless of metaprogramming). So for me, the only reason we'd be striving for something better is to fully solve compile-time safety issues. But this proposal doesn't quite address it to me due to the various concerns expressed before. |
Compile-time print-debugging could be useful. |
@rrousselGit: |
Now that I think about it, isn't this just const mirrors? At least, the useful part of it is. |
@rrousselGit I don't think that reflectable is that useful here. It majorly limits what classes and types you can work with to only those explicitly annotated and generated. |
If authors are shipping code to production because it compiles without error in development, but they aren't running that code in development to check for runtime errors, I don't think there is much we can do to protect them from shipping bugs. I agree with the value of moving error discovery as close to error introduction as possible. I don't think it's a significant problem for an error to be compile time in production compiles and runtime in dev compiles. The opposite direction - an error that is surfaced at compile time in dev mode but held until runtime in production mode - would be a problem, but unless I'm misunderstanding I don't think that's the case we're worried about here. |
Yeah I think some of the previous replies may have been mistaking "no error in dev, yes error in release" to mean "the error doesn't appear during development, it only appears after releasing the app to customers". Release != runtime. |
Dart never had this problem until this proposal :) Ultimately it's not a case of "people aren't running that code to check". It's much nastier than this: View it like this: To me those kind of arguments feel odd. I feel like I'm hearing arguments for writing Javascript instead of Typescript, or that null safety in Dart shouldn't have been necessary. |
I'd assume that'd still be the case; it's just that this proposal doesn't try to constrain the specification too much as to allow performant implementations to be possible.
But that wouldn't happen? In your scenario it would work in debug mode, but then not compile for release, because the VM implementation of @konst would in this example be able to do something that AOT can't do. As far as I understand it the VM implementation might risk of being able to do stuff that AOT can't, but not the other way around? |
Depends on your definition of "ship". I'd bet 100$ that those two things will happen:
None of these would happen if dev builds behaved like release builds. |
I don't think any of this is really new, though; you can already publish kinda broken packages on pub. For example by pushing a new release with a breaking change that's not marked as such in the package version. |
Yeah, because users will notice this at their first That's exactly the point. We can't give up this experience. Also:
The above reads like "if folks don't build a production release in months, that's on them". When should be the correct time to build in release mode? How often? How slow should my dev cycle become just because I can't trust I can't be the only one imagining this like a nightmare. Dart de-facto won't be safe anymore... |
FUD in comments.
Are differences in semantics inevitable? Skimming through json example, what could trigger errors in a deployment compilation after dev? ...
void invokeBar(Map<String, Object> values) {
invoke(bar, [], named: {
for (@konst final k in ['a', 'b'])
k: input['k'],
});
// expands to bar(a: input['a'], b: input['b'])
} It shouldn't be limited to collection literals if all compilers use one common algorithm of detecting shapes. Searching in AST or something. Compilability checks must be identical for all compilers. It must be feasible.. |
I'm not worried about code that uses Say in a large codebase, folks do: class Person {
late String name;
late int age;
String get displayName => '$name ($age)';
}
...
encode(Person(...)); The following diff would break class Person {
late String name;
late int age;
String get displayName => '$name ($age)';
+ final NotSerializable oopsy;
} In this example, Your test suite could be: test('Person', () {
final p = Person()..name = 'John'..age=21;
expect(p.displayName, 'John (21)');
}) This test would pass ; because That's different from json_serializable today. If you did the previous change in json_serializable would fail at generation and tell you that no |
I have this prototype implementation built, but can't understand how to test the described situation, or what to test. |
You may have dependencies from pubspec.yaml needed in This would conveniently also be helpful when collocating tests because currently, when collocating, test specific deps cannot be in |
Probably not possible to depend on dev-dependencies. |
Code that uses
Here
means that developer can write their code containing reflective code, not quite debug. This Map<String, Object?> encode<T>() const {
const mapper = {
for (const field in TypeInfo.of<T>().fields)
if (!field.methods.any((m) const => m.name == 'toJson'))
throw Error('Missing toJson')
else
field.name: (T self) => /* encode using invoke & field.getFrom(value) */
};
return (T self) => {
for (const field in mapper.entries)
field.key: field.value(self),
};
} I was hacking the proposal a little bit, implemented Map<String, Object?> encode<@konst T>() {
final tinfo = TypeInfo.of<T>();
var mapper = <String, Object?>{
for (@konst final f in tinfo.fields)
for (@konst final m in f.type.methods)
if (m.name == 'toJson')
f.name: () { return "here"; },
//break; // not implemented for comptime
//else
// "otherwise" branch
//throw Error();
};
return mapper;
} Compiler can execute only code that is written specifically to be executed at comptime, it's impossible to pass as parameter to a library function any lambda and expect it to be folded during compilation. Now I look, what if, there can be comptime predicate functions that are defined just by their return type |
I've been thinking: But: The main reason build_runner is slow is because it relies on the Element tree instead of the raw AST. That scales very poorly with imports as the number of files increases. At the same time, relying esclusively on the AST doesn't have this scaling issue. But it suffers from severe limitations: Code-generators can't perform logic conditionally based on the type of a variable There comes |
Actually, the resolved element model is available quickly enough: it has to be, or your IDE experience would be too slow. Fundamentally, the analyzer and CFE are the only ways of getting the resolved source information we have, so you can't be faster than they are, whether via |
I'm not saying that getting the Element tree is slow. Which is why small projects are fast, but large ones are slow. Because modifying a file in a small project WI only invalidate one or two files. But on large ones, it'll invalid are hundreds if not thousands of files. Relying on the AST only would have large projects be as fast as small projects (at least until you do a clean) ; because editing one file would always only generate one file (well two if you use freezed+json_serializable 😜) |
I think @konst is pretty sweet even if it doesn't replace codegen (which isn't going to go away anyways). It's a different thing and it can do things that would be pretty inconvenient / hard to do with codegen. |
What if the generator does rely on those imports? It often does, such as checking for .toJson on field types. Though aside from that if we had a way to indicate what we actually cared about, so it only invalidates if, say, the type changed, but not if some other irrelevant thing changed. But that also might get super complicated, so idk. |
Having some code generation that does not require types would be interesting however, maybe something like declarative / hygienic macros in Rust? https://doc.rust-lang.org/reference/macros-by-example.html This would be enough to create your data classes from some primary constructors-like syntax. |
just a note if someone would like to make analysis_server/LSP work with this proposal's code w/o error: Executable path for LSP: sdk/out/ReleaseX64/dart-sdk/bin/dart Insert "metaprogramming" string to this build var: Insert SDK metainfo about new core library "dart:metaprogramming" 'metaprogramming': const LibraryInfo(
'metaprogramming/metaprogramming.dart',
categories: 'Client',
maturity: Maturity.EXPERIMENTAL,
), |
Some kind of compile time const would be good, that will make flavor much easier to use. |
About |
@ykmnkmi no, this sort of stuff is outside of my comfort zone for this feature. That's seems like a task for some external code generation tool. If you import all types into the entry point you can build router from the list of types though. |
tldr: I propose we follow the lead of D, Zig and C++26 when it comes to metaprogramming. We introduce an optional (toolchain) feature to force compile time execution of certain constructs. We add library functions to introspect program structure which are required to execute in compile time toolchain supports that. These two together should give enough expressive power to solve a wide range of problems where metaprogramming is currently wanted. cc @dart-lang/language-team
History of
dart:mirrors
In the first days of 2017 I have written a blog post "The fear of
dart:mirrors
" which contained started with the following paragraph:In 2017 type system was still optional, AOT was a glorified "ahead-off-time-JIT", and the team maintained at least 3 different Dart front-ends (VM, dart2js and analyzer). Things really started shifting with Dart 2 release: it had replaced optional types with a static type system and introduced common front-end (CFE) infrastructure to be shared by all backends. Dart 3 introduced null-safety by default (NNBD).
And so 8 years and many stable releases later Dart language and its toolchains have changed in major ways, but
dart:mirrors
remained in the same sorrowful state: a core library only supported by the native implementation on Dart, only in JIT mode and only outside of Flutter.How did this happen?
The root of the answer lies in the conflict between Dart 1 design philosophy and necessity to use AOT-compilation for deployment.
Dart 1 was all in on dynamic typing. You don't know what a variable contains - but you can do anything with it. Can pass it anywhere. Can all any methods on it. Any class can override a catch-all
noSuchMethod
and intercept invocations of methods it does not define. Dart's reflection systemdart:mirrors
is similarly unrestricted: you can reflect on any value, then ask information about its type, ask type about its declarations, ask declared members about their parameters and so on. Having an appropriate mirror you can invoke methods, read and write fields, instantiate new objects.This ability to indirectly act on the state of the program creates a problem for static analysis of the program and that in turn affects ability of the AOT compiler to produce a small and fast binary.
To put this complexity in simple terms consider two pieces of code:
When compiler sees the first piece of code it can easily figure out which
method
implementation this call can reach and what kind of parameters are passed through. With the second piece of code analysis complexity skyrockets - none of the information is directly available in the source code: to know anything about the invocation compiler needs to know a lot about contents ofm
,args
andname
.While it is not impossible to built static analysis which is capable to see through the reflective access - in practice such analyses are complicated, slow and suffer from precision issues on real world code.
AOT compilation and reflection is pulling into opposite directions: AOT compiler wants to know which parts of the program are accessed and how, while reflection obscures this information and provides developer with indirect access to the whole program. When trying to resolve this conflict you can choose between three options:
Facing this choice is not unique to Dart: Java faces exactly the same challenge. On one hand, the package
java.lang.reflect
provides indirect APIs for accessing and modifying the state and structure of the running program. On the other hand, developers want to obfuscate and shrink their apps before deployment. Java ecosystem went with the second option: shrinking tools more-or-less ignore reflection and developers have to manually inform toolchain about the program elements which are accessed reflectively.Note
There has been a number of attempts to statically analyze reflection in Java projects, but they have all hit issues around scalability and precision of the analysis. See:
Graal VM Native Image (AOT compiler for Java) attempts to fold away as much of reflection uses as it can, but otherwise just like ProGuard and similar tools relies on the developer to inform compiler about reflection uses it could not resolve statically.
R8 (Android bytecode shrinker) has a special troubleshooting section in its
README
to cover obscure situations which might arise if developer fails to properly configure ProGuard rules to cover reflection uses.Reflekt: a Library for Compile-Time Reflection in Kotlin describes a compiler plugin based compile time reflection system similar in some ways to
reflectable
.Compile-time Reflection and Metaprogramming for Java covers a metaprograming system which proposes metaprogramming system based on compile-time reflection.
Dart initially went with the first option and tried to make
dart:mirrors
just work when compiling Dart to JavaScript. However, rather quicklydart2js
team started facing performance and code size issues caused bydart:mirrors
in large Web applications. So they switched gears and tried the second option: introduced@MirrorsUsed
annotation. However it provided only a temporary and partial reprieve from the problems and was eventually abandoned together withdart:mirrors
.There were two other attempts to address code size issues caused by mirrors, while retaining some amount of reflective capabilities: now abandoned package
smoke
and still maintained packagereflectable
. Both of these apply similar approach: instead of relying on the toolchain to provide unrestricted reflection, have developer opt-in into specific reflective capabilities for specific parts of the program then generate a pile of auxiliary Dart code implementing these capabilities.Note
Another exploration similar in nature was (go/const-tree-shakeable-reflection-objects)[http://go/const-tree-shakeable-reflection-objects].
Fundamentally both of these approaches were dead ends and Web applications written in Dart solved their code size and performance issues by moving away from reflection to code generation, effectively abandoning runtime metaprogramming in favor of build time metaprogramming. Code generators are usually written on top of Dart's analyzer package: they inspect (possibly incomplete) program structure and produce additional code which needs to be compiled together with the program.
Following this experience, we have decided to completely disable
dart:mirrors
when implementing native AOT compiler.Note
For the sake of brevity I am ignoring discussion of performance problems associated with reflection for now. It is sufficient to say that naive implementation of reflection is guaranteed to be slow and minimizing the cost likely requires runtime code generation - which is not possible in all environments.
Note
If you are familiar with intricacies of Dart VM / Flutter engine embedding you might know that Dart VM C API is largely reflective in nature: it allows you to look up libraries, classes and members by their names. It allows you invoke methods and set fields indirectly. That why
@pragma('vm:entry-point')
exists - and that is why you are required to place it on entities which are accessed from outside of Dart.const
Let me change gears for a moment and discuss Dart's
const
and its limitations. This feature gives you just enough power at compile time to:const
constructors),int
anddouble
valuesbool
valueslength
of a constantString
Exhaustive list is given in section 17.3 of Dart Programming Language Specification and even though the description occupies 5 pages the sublanguage it defines is very small and excludes a lot of expressions which feel like they should actually be included. It just feels wrong that
const x = [].length
is invalid whileconst x = "".length
is valid. For some seemingly arbitrary reasonString.length
is the only blessed property which can't be accessed in a constant expression. You can't write[for (var i = 0; i < 10; i++) i]
and so on.Consider the following code from
dart:convert
internals:It feels strangely limiting that the only way to update this constant is to modify the comment above it, copy that comment into a temporary file, run it and paste the output back into the source. What we really want is to define
_characterAttributes
in the following way:This requires the definition of constant expression to be expanded to cover a significantly larger subset of Dart than it currently includes. Such feature does however exist in other programming languages, most notably C++, D, and Zig.
C++
Originally metaprogramming facilities provided by C++ were limited to preprocessor macros and template metaprogramming. However, C++11 added
constexpr
and C++20 addedconsteval
.The following code is valid in modern C++ and computes
kCharacterAttributes
table in compile time.Note
C++26 will most likely include reflection support which would allow the program to introspect and modify its structure in compile time. Reflection would allow programmer achieve results similar to those described in the next section about D. I am omitting it from discussion here because it is not part of the language just yet.
D
C++ example given above can be trivially translated to D, which also supports compile time function execution (CTFE).
D however takes this further: it provides developer means to introspect and modify the structure of the program itself in compile time. Introspection is achieved via traits and modifications are possible via templates and template mixins.
Consider the following example which defines a template function
fmt
capable of formatting arbitrary structs:When you instantiate
fmt!Person
compiler effectively produces the following codeSee Compile-time vs. compile-time for an introduction into D's compile-time metaprogramming.
Zig
Zig metaprogramming facilities are centered around
comptime
- a modifier which requires variable to be known at compile-time. Zig elevates types to be first-class values, meaning that you can put type into a variable or write a function which transforms one type into another type, but requires that types are only used in expressions which can be evaluated in compile-time.While Zig's approach to types is fairly unique, the core of its metaprogramming facilities is strikingly similar to D:
std.meta.fields(@TypeOf(o))
is equivalent of D's__traits(allMembers, T)
, while@field(o, name)
is equivalent of__traits(getMember, o, name)
.inline for
is expanded in compile time just like D'sstatic foreach
.Here is an example which implements a generic function
print
, similar to genericfmt
we have implemented above:Dart and Platform-specific code
Dart's does not have a powerful compile time execution mechanism similar to those described above. Or does it?
Consider the following chunk of code which one could write in their Flutter application:
Developer compiling their application for Android would naturally expect that the final build only contains
AndroidSpecificWidget()
and notIOSSpecificWidget()
and vice versa. This expectation is facing one challenge:defaultTargetPlatform
is not a simple constant - it is defined as result of a computation. Here is its definition from Flutter internals:None of
Platform.isX
values areconst
's either: they are all getters on thePlatform
class.This seems rather wasteful: even though AOT compiler knows precisely which platform it targets developer has no way of writing their code in a way that is guaranteed to be tree-shaken based on this information. At least not within the language itself - last year we have introduced support for two
@pragma
s:vm:platform-const-if
andvm:platform-const
which allow developer to inform the compiler that a function can and should be evaluated at compile time if compiler knows the platform it targets.These annotations were placed on all API surfaces in Dart and Flutter SDK which are supposed to evaluate to constant when performing release builds:
An implementation of this feature leans heavily on an earlier implementation of
const-functions
experiment. This experiment never shipped as a real language feature, but CFE's implementation of constant evaluation was expanded to support significantly larger subset of Dart than specification currently permits forconst
expressions, including imperative loops, if-statements,List
andMap
operations.Static Enough Metaprogramming for Dart
Let us first recap History of
dart:mirrors
: reflection posed challenges for Dart because it often makes code impossible to analyze statically. The ability to analyze the program statically is crucial for AOT compilation, which is the main deployment mode for Dart. Dart answer to this was to shift metaprogramming from run time to (pre)build time by requiring code generation: an incomplete program structure can be inspected viaanalyzer
package and additional code can be generated to complete the program. This way AOT compilers see a static program structure and don't need to retain any reflective information.To put it simply, we avoid reflection because our AOT compilers can't analyze it and fold it away. Conversely, if compiler could analyze and fold reflection away we would not need to avoid it. Dart could have its cake and eat it too. D, Zig (and C++26) show us the path: we need to lean on compile time constant evaluation to achieve that.
I propose we introduce a special metadata constant
konst
in thedart:metaprogramming
which would allow developer to request enhanced constant evaluation at compile time if the underlying compiler supports it.Applying
@konst
to normal variables and fields simply requests compiler to compute their value at compile time:When
@konst
is applied to parameters (including type parameters) it turns functions into templates: compiler will require that annotated parameter is a constant known at compile time and clone the function for a specific combination of parameters. The original function is removed from the program: it is impossible to invoke it dynamically or tear it off. To annotatethis
as@konst
developer will need to place@konst
on the declaration of the function itself.Important
Here and below we assume that constant evaluator supports execution of functions (i.e. as implemented by
const-functions
language experiment) - rather than just a limited subset of Dart required by the language specification. This means[1].first
and even[1].map((v) => v + 1).first
can be folded to a constant when used in@konst
-context.When
@konst
is applied to loop iteration variables it instructs the compiler to expand the loop at compile time by first computing the sequence of values for that iteration variable, then cloning the body for each value in order and substituting iteration variable with the corresponding constant.Generics introduce an interesting caveat though:
We could expand
dart:metaprogramming
with atypeOf(...)
helper:But that does not solve the problem. Type arguments and normal values are separated in Dart - which means you can't invoke a generic function with the given
Type
value as type argument, even ifType
value is a compile time constant. To breach this boundary we need a helper which would allow us to constructing function invocations during compile time execution.For example:
Note
Function.apply
does not support passing type arguments to functions, but even if it did we would not want to use it here because we want to enforce compile time expansion ofinvoke(...)
into a corresponding call or an error, if such expansion it not possible.Combining
typeOf
andinvoke
yields expected result:You might notice that
invoke
is a bit wonky:f
is@konst
, but neitherposition
, nornamed
, nortypes
are. Why is that? Well, that's becauseinvoke
tries to capture expressivity of a normal function call site: each call site has constant shape (e.g. known number of positional and type arguments, known names for named arguments), but actual arguments are not required to be constant. Dart's type system does not provide good tools to express this,List
andMap
don't have their shape (e.g. length or keys) as part of their type.This unfortunately means that compiler needs to be capable of figuring out the shape of lists and maps that flow into
invoke
. Consider for example that we might want to construct argument sequence imperatively:Should this code compile? Maybe we could limit ourselves to supporting only collection literals as arguments to
invoke
:@konst
reflectionFeatures described above lay the foundation of compile time metaprogramming, but for it to be complete we need to expose more information about the structure of the program.
For example (these are not exhaustive or exact):
Note that all methods are annotated with
@konst
so if compiler supports@konst
these must be invoked on constant objects and will be folded away - compiler does not need to store any information itself.It's a spectrum of choice
I have intentionally avoided saying that
@konst
has to be a language feature and that any Dart implementation needs to support compile time constant evaluation of@konst
. I think we should consider doing this as a toolchain feature, similar to howplatform-const
is implemented.For example, a native JIT or DDC (development mode JS compiler) could simply implement
TypeInfo
on top of runtime reflection. This way developer can debug their reflective code as if it was any other Dart code. A deployment compiler (native, Wasm or JS) can then fold the cost of reflection away by enforcing const-ness requirements implied by@konst
and folding away reflective operations.Note
A deployment compiler can even choose between producing specialized code by cloning and specializing functions with
@konst
-parameters or it could choose to retain reflective metadata and forego cloning at the cost of runtime performance. This reduces the size of deployed applications but decreases peak performance.In this model, developer might encounter compile time errors when building release application which they did not observe while developing - as development and deployment toolchains implement different semantics.
I think that's an acceptable price to pay for the convenience&power of this feature. We can later choose to implement additional checks in development toolchains or analyzer to minimize amount of errors which are only surfaced by release builds. But I don't see this as a requirement for shipping this feature.
Prototype implementation
To get the feeling of expressive power, implementation complexity and costs I have thrown together a very rough prototype implementation which can be found here. When comparing manual toJSON implementation with a similar (but not equivalent!) one based on
@konst
reflection I got the following numbers:I think the main difference between manual and reflective implementations is handling of nullable types and lists. Manual implementation inlined both - while reflective leaned on having helper methods for these. I will take a closer look at this an update this section accordingly.
Example: Synthesizing JSON serialization
Note
These are toy examples to illustrate the capabilities rather than full fledged competitor to
json_serializable
. I have written this code to experiment with the prototype implementation which I have concocted in a very limited time frame.toJson<@konst T>
fromJson<@konst T>
Example: Defining
hashCode
and==
We could also instruct compiler to handle
mixin
's (and possibly all generic classes) with@konst
type parameters in a special way: clone their declarations with known type arguments. This would allow to write the following code:References
The text was updated successfully, but these errors were encountered: