-
Notifications
You must be signed in to change notification settings - Fork 209
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
static enough metaprogramming #4271
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
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. |
@jakemac53 I think I should include some explicit remarks about compiling/analyzing against outlines in the proposal. That's one of the reasons I formulated proposal in the way I did, but I did poor job stating it. Only AOT compilation toolchains will need to do |
Is the idea then that the analyzer would never execute these functions at all? That would certainly speed up analysis, with the trade-off that you may have compile time errors that the analyzer doesn't report. It could only execute these for open files or something like that though, possibly. In general I think the idea of not actually executing these at compile time during the development cycle is an interesting idea, although we definitely will have to be thoughtful about how hot reload will work, and also bear in mind that it will slow down the startup of all dart apps potentially (in dev mode) - you have to evaluate all these every time an app launches if I understand correctly, instead of having the evaluation be performed once at compile time and then cached (hopefully) for rebuilds. |
There is no extra execution on start-up. If the It's normal code that can be constant evaluated/specialized to improve tree-shaking. (Well, normal code that can do a limited kind of reflection.) |
It still happens at runtime either way, even if it is only on first access. Maybe it is fine, I don't really know. It depends on how prevalent the usages are. Probably roughly equivalent to using mirrors today. |
Love the proposal. (I always enjoy and appreciate long-form writing from @mraleph). I am currently working on combining a native game engine with the usability and ergonomics of Dart. This proposal looks invaluable from that standpoint. |
This is a really cool proposal. @jakemac53 and I did do some investigation of C++ and Zig early on in the macro design process. That's partially where "enhanced constant functions" came from. As you note, it has a lot of really nice properties. A couple of thoughts: Fields versus gettersI agree with @lrhn that allowing reflective access to the fields of an arbitrary class breaks encapsulation and uniform access. Personally, I think uniform access is an important feature of Dart. I like that class authors are free to change fields to getters and vice versa without breaking clients. More importantly, I like that class authors aren't encouraged to preemptively wrap every field in a getter just in case they need that abstraction later. I believe we could address that while doing this proposal by restricting what you're allowed to introspect on. The simplest approach would be to say you can only introspect on the fields of the surrounding class. A little more powerful would be allowing something like "template mixins" where a mixin is allowed to introspect over the class its applied to. In both cases, the class author has full control over whether the class is introspectable. I wouldn't want to allow unbounded introspection over arbitrary types. Encapsulation is important. No declaration-level metaprogrammingThis feature works when the place where you need to do some metaprogramming is only inside a function body. For example, it works well for methods like But it doesn't handle other use cases like It may be that we can simply live without supporting those use cases. Or it may be that some kind of "record spread" support in parameter lists and/or field declarations could help. But I suspect that this is a severe enough limitation that it means the feature wouldn't carry its weight. It's still quite a lot of complexity and in return you only get the ability to do metaprogramming inside function bodies. If you look around at the places where users are using code generation in Dart today, it's very often adding new declarations, and this wouldn't help for that. |
In zig, you can generate the definition of a new struct (=class) in comptime. See the examples here: https://mht.wtf/post/comptime-struct/ |
I'm curious if you could use this to create a version of That way the hooks implementation could use the keys to detect divergence, but also to converge again later; ie you could use hooks inside of if statements, similar to Jetpack Compose. Now to be honest I'm not exactly sure how it would work with the proposed system, but I think each Since the key is generated during @konst it would be specific to each Now, would that work? Or something like this? |
Oh another issue could be that since numbers are slightly differently implemented in dart2js than the 'native' Dart targets, that the result of a static calculation or bit shift operation could be different than the same operation in JavaScript at runtime. |
Number semantics are already an issue in |
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: