dart-lang / language

Design of the Dart language
Other
2.65k stars 201 forks source link

Introduce additional primitive members #2219

Open eernstg opened 2 years ago

eernstg commented 2 years ago

Cf. https://github.com/dart-lang/language/issues/1296#issuecomment-1109428611, https://github.com/dart-lang/language/issues/1296#issuecomment-1110075607. Issue #1296 contains several other comments that are relevant to this discussion, but the topic is separate from that of #1296, so here is an issue where the addition of primitive members is the main topic.

This topic covers a number of requests for specific enhancements of the expressive power of constant expressions. In particular, the ability to perform sanity checks (in an assert) on constructor arguments in a constant constructor has been requested many times.

The language specification currently specifies what it means to have a primitive operator ==. Essentially, this means that the implementation is guaranteed to be provided by the system, which again makes it possible to guarantee that the corresponding behavior can also be provided during constant expression evaluation at compile time.

We could introduce the corresponding notion of being primitive for any member whose implementation is guaranteed to be system provided, thus extending the set of constant expressions with things like myList.length, mySet.isNotEmpty, etc. Note that even a member like Set.contains could be handled, in spite of the fact that it is not a getter, because the semantics of an invocation is known and can be performed during constant expression evaluation.

Here is a table showing the rather large number of cases where we could do this (based on a table in this comment by @lrhn):

Member Receiver static types Receiver
length, isEmpty, isNotEmpty Iterable, Map value of constant collection literal
contains, first, last, single Iterable value of constant collection literal
operator [] List value of constant list literal
containsKey, containsValue,
operator []
Map value of constant map literal
isNaN, isFinite, sign, ceil, floor, round,
ceilToDouble, floorToDouble, roundToDouble
num double, int
substring, charCodeAt, contains,
startsWith, endsWith
String String

We would need a number of extra constraints, including:

Of course, we could do this to a tiny extent (e.g., just add length for lists), or we could do all of it, and we could do it incrementally over time.

lrhn commented 2 years ago

For contains on Iterables, the collection elements must have primitive equality. (Might restrict it to List and Set, instead of any Iterable.) For constainsValue on Map, the values must have primitive equality. (For containsKey and operator[], the keys must too, but they already had to in a const map literal.) For contains and split on String, the argument must be a String (not any Pattern), as already stated.

The String.split might have to be dropped because it returns a mutable list normally, and would have to return a const list if evaluated as const, and we don't usually allow const evaluation to behave differently from non-const evaluation. (It's an optimization to actually do the constant evaluation at compile-time,) It would mean that "abba".split("b") in a non-const context would not be constant. Or? (We currently ignore that something is a "constant expression" when it's not required to be one, and allow compilers to canonicalize strings, or not do so, as they see fit.)

eernstg commented 2 years ago

Thx! I adjusted the table. I did not change the static receiver type for operations on iterables, because the restrictions on the actual receiver already restricts the operations to objects of type List or Set, and the static type could actually be Iterable. I removed split because it seems to be a step too far to introduce this semantic discrepancy (being mutable or not).

rakudrama commented 2 years ago

There has not been much discussion here about making more constructors const. I recently want a const String.fromCharCode and String.fromCharCodes.

eernstg commented 2 years ago

@rakudrama, perhaps the request for constant constructors of String should be made in the SDK repo as a core library issue?

I do recognize that it is not possible to have an external const factory constructor in Dart without going outside the language, but I still think that "the language Dart" (and, in particular, the language specification) wouldn't need to know about those constant constructors, it just needs to know that an instance creation that invokes a constant constructor can be a constant expression.

With that in place, we can say that this issue is exclusively about extending the set of constant expressions (by adding .length on lists etc.).

cedvdb commented 2 years ago

operator []

Would this allow tighter tree shaking somehow on maps ? If I recall correctly one of the test I tried a while ago const maps weren't tree shaken (on web) but I could be wrong. IE if only a subset of elements are accessed with constants values.

eernstg commented 2 years ago

tighter tree shaking

You could say that there is a potential benefit there, because we could conceptually consider m[k] to be a single reference when m is a constant list or map and k is a constant. In that sense, const xs = [1, 2]; would introduce the "references" xs[0] and xs[1], and then we'd just tree shake xs[1] if it is never used.

However, this stops working as soon as the program drops a bit of information: If we're passing xs as an argument to a function call (with any suitable declared type, e.g., List<int> or Object) then we can't keep track of any variable being or not being that same map. So if we have ys[1] somewhere (where ys could have type List<num>, dynamic, etc), it might access xs[1]. Similarly, if we have a run-time expression e, and we're accessing xs[e] or ys[e], then it might again access xs[1].

So there isn't much hope that we could remove any elements from a list, or any key/value pairs from a map, based on a static analysis. Tree shaking works a lot better when we have a declaration which is known to be unused because no identifier in the program resolves to that declaration.

I created a proposal which is somewhat relevant here: https://github.com/dart-lang/language/issues/371, about 'link time sets and maps'. The main idea for those is that they are populated by things that are reachable already. So if you have a class C which is unused then it will be ignored, but if you are using it (e.g., by creating an instance of it) then it's already surviving the tree shaking process, and in that case it will be taken into account when the link time sets and maps are created. For instance, we might then have a link time set of factory functions, and we could add some constructors of C to that set.

This basically means that we're tree-shaking some elements out of the set and similarly for a map (e.g., a constructor tear-off like C.new), because some associated elements (like the class C) can be tree-shaken out of the program already, so it is at least somewhat similar to tree-shaking on the map itself.

rakudrama commented 2 years ago

Consider adding toInt and toDouble for num receivers.

cedvdb commented 2 years ago

Ultimately what's the limit here? Does it make the compiler more complex ? Is it human time ? Is it performances ?

would this work:

const MyClass(List<int> list) : assert(checkBlackListed(list));
static double checkBlackListed(List<int> value)  => !(const [1,2].contains(value));

Personally, it would have a positive impact if some exceptions could be caught before runtime (especially in tests where the values are often hardcoded), and I'd assume this type of feature is on the low budget side of implementation time compared to static metaprogramming, so I hope it can make it soon.

eernstg commented 1 year ago

Here is another candidate enhancement: If r is a constant expression whose value is a record with a getter g (could be $1 or someName), r.g is a constant expression.

Cf. https://github.com/dart-lang/sdk/issues/51895.