[Pitch] Compile-Time Constant Values

I think compexpr or constexpr could be nice if used like:

let name = constexpr "stackotter"

I've been thinking about the importance of strong defaults in a language. imo, in a programming language you want the "best" option to require the least thought to use. While syntactically I like your proposal, having to remember to do let name = constexpr "stackotter" every time I make a string (well, constexpr would unnecessary for a string literal) means it may not often be used. If on the hand the syntax were

const name = "stackotter"

it makes it pretty simple for me to change my "default" variable declaration from let to const

3 Likes

Agree 100%. Imagine if we have to write

var name = immutable “name”

Instead of

let name = “name”

Having both let and const in an assignment doesn’t convey any new information, it just adds verbosity imo.

Additionally, I feel like extra annotations like this should add extra functionally (e.g. inout, @escaping [not exactly the same, but same idea]), not take it away, which const sort of does.

It would also be nice if there was a compiler warning to change let to const if possible, like from var to let, which works well with the idea of const being the default.

4 Likes

It's too bad because from a syntax and grammar perspective I like let name = constexpr "name" better. But from a DX perspective I like const name = "name" better

1 Like

These two things don't have to be mutually exclusive. const name = constexpr "name" is syntactically ridiculous but conceptually fine; const name makes name usable in subsequent constexpr evaluations, and constexpr ensures that the expression is evaluated by the front-end. Both concepts are useful. We can just have constexpr be implicit for the initializer of const name, such that const name = "name" is actually const name = constexpr "name".

2 Likes

such that const name = "name" is actually const name = constexpr "name" .

That's a great idea. In that case, we can get rid of const name as a special initializer and just have const name = "name" be syntactic sugar for let name = constexpr "name". This makes forms like

func nameAndAge(name: constexpr String, age: constexpr Int) -> constexpr String

simpler.

Otherwise we'd have to ask in :point_up_2: if name is a let or const. (I don't know if this is even a consideration in the compiler anymore since variable parameters like func nameAndAge(var name: String) was removed in Swift 3)

About compile time expressions. Maybe it's not related, but ... It would be nice if bitwise operation was (pre)compile time operation (if possible).

For example, there is a warning if you initialize OptionSet with rawValue as 0. But there is no warning if bitwise operation's result is zero.

struct ShippingOptions: OptionSet {
    let rawValue: Int8
    
    static let nextDay    = ShippingOptions(rawValue: 0) // warning
    static let secondDay  = ShippingOptions(rawValue: 1 << 1)
    static let priority   = ShippingOptions(rawValue: 1 << 2)
    static let standard   = ShippingOptions(rawValue: 1 << 333) // actual 0, no warning
}
1 Like

Returning to the current pitch, one thing I can't really make sense of is how it grows into the feature it wants to be. Constant evaluation of functions and instantiation of objects are on the roadmap, but it seems like they would deprecate large parts of the current first step. Once we have constant evaluation of functions, it stops making sense to have const-annotated parameters (the function allowing constant evaluation transitively requires its arguments to be const-evaluated). Once we have constant-evaluated instantiation of objects, const on instance properties also stops being very useful because evaluating the initializer at compile-time implies all fields have a value computed at compile time. (What's worse, if you want compile-time evaluation of mutating methods, you can't use const on any fields that you want to have mutable!)

This leaves us with static const properties and requirements, which I think will continue to have value going forward.

2 Likes

Arguably it's better if 'const' is orthogonal to 'let' because we might want it to apply to other kinds of declarations, such as 'func'. Also given that this is an advanced, rarely-used feature, making the declaration site concise shouldn't really need to be a goal.

4 Likes

I have a very superficial suggestion: we should consider spelling this as @const rather than const. My arguments are as follows:

  • this behaves more like an attribute than a keyword since it doesn't fundamentally change the behavior of the declaration, rather it restricts it in some way, similar to @objc (there is also an analogy with property wrappers here).

  • this is an advanced, rarely-used feature so burning part of the lexical grammar on a new contextual keyword might be undesirable.

  • the @ jumps out at the reader, immediately making it clear something special is going on.

Unfortunately I don't have any more substantiative feedback on the pitch at this time, but I'd really like us to consider @const rather than const. For some reason const rubs me the wrong way.

26 Likes

Is it, though? We'd likely want either the same or a very similar spelling for functions (not just values) that must be evaluable at compile-time. That then extends to every function called by those functions, and suddenly we're in C++ "constexpr all the things!" land.

For those unfamiliar with C++, this means that every function that possibly can be constexpr should be constexpr, otherwise you won't be call it from compile-time contexts. So it spreads everywhere; even library authors who aren't interested in compile-time meta programming need to tag their functions as constexpr.

3 Likes

Like @usableFromInline? :wink:

Actually it isn't like @usableFromInline:

If your function is @inlinable, everything it calls must be @inlinable (recursively), so that is like constexpr -- but @usableFromInline serves as a cap which breaks that recursive rule and allows you to include non-inlinable functions and declarations.

That kind of approach works for inlining, because theoretically inlining shouldn't affect semantics, only performance/code-size. So it's okay to have some leaf functions opt-out of inlining. Compile-time evaluation isn't like that; if a leaf function opted-out and was evaluated at run-time instead, it could very easily affect program semantics and mean the top-level @const/constexpr assertion would no longer be accurate.

6 Likes

I think @Slava_Pestov and us have fundamentally different ideas of what this features' place is in the landscape of Swift.

Because of this, I think we need to explicitly ask:

Do we expect compile-time constant values to be a core part of Swift that should be used frequently, or a hidden advanced feature to be used only in certain situations?

When I made my proposal I was assuming it was the former. That when I'm programming I would essentially use const name = "name" (which is syntactic sugar for let name = constexpr "name") as a default, and use let when that doesn't work. Similar to how I work in Rust.

I think it all comes down to this question. If we decide this is an advanced feature to only be used sparingly, then I :100:% agree with @Slava_Pestov and think the @const syntax makes more sense

3 Likes

Not in all cases. There are use cases where you want an argument to be guaranteed const, but the function is not (fully) evaluated at compile time.

Take for example the recently pitched non-failable initializer for Foundation.URL. The goal there is to require the string argument to be compile-time constant, but creation of a URL type cannot be done at compile time. Eventually Swift's constant evaluation may be able to handle validating URLs, but you may never be able to express a compile-time constant URL type because that type lives in an ABI-stable SDK and so needs to be opaque, whereas compile-time type creation needs a level of transparency to the compiler.

Even if those issues are all overcome in time, there are still use cases for constant arguments to non-compile-time-evaluated functions. For example, suppose you were replicating Darwin's OS log functionality. That requires you pass in a compile-time constant interpolation string, which gets transformed at compile time into a fixed-width representation (i.e. "\(foo) bar \(baz)" becomes "%s bar %d" at compile time plus some code that packs foo and baz into a payload at runtime). The actual act of logging, though, must of course happen at runtime not compile time.

9 Likes

This comes up a lot when we talk about binding intrinsics, too. E.g. we might want to bind AVX-512 static rounding directions with a rounding mode: FloatingPointRoundingMode argument, but that value gets encoded directly into an instruction when compiled, so we would need to be able to require that it's a compile-time constant visible to the compiler, even though the other arguments are not.

5 Likes

I think that you're effectively pitching non-type generic arguments constrained such that the receiving function can't use them as compile-time values. :)

For both use cases that you proposed, you could be served as well or better by types that are only instantiable at compile-time. For instance, you could have a StaticURL type that is ExpressibleByStringLiteral and only instantiable at compile-time and URL could have a non-failable StaticURL initializer. You can have about the same thing for an os_log reimplementation. The function doesn't need to know what the value was specifically, it just needs to know it has been instantiated at compile-time.

One thing I don’t like with const parameters is that the ABI implications are not amazing either way we go because there’s nothing that changes in the function’s implementation in response to it. Either we don’t make it ABI and agree that it doesn’t matter very much if it changed from under your feet, or we make it ABI and relatively non-obvious that adding or removing const is a breaking change despite codegen being identical. To me this points at const parameters being the wrong change in the type system. On the other hand, it’s easy to understand the ABI effect of swapping out URL for StaticURL (for instance), or of adding or removing non-type generic parameters.

IMO @scanon's problem is begging for non-type generic arguments more than compile-time evaluation broadly, but I think that even today, at least for this specific use case, there are decent solutions. You could get away reasonably well defining a different enum for each overload:

enum StaticRoundMode {
	enum AwayFromZero { case awayFromZero }
	enum Down { case down }
	enum ToNearestOrAwayFromZero { case toNearestOrAwayFromZero }
}

func avxSuperAdd(_ lhs: Float, _ rhs: Float, rounding: StaticRoundMode.AwayFromZero) -> Float
func avxSuperAdd(_ lhs: Float, _ rhs: Float, rounding: StaticRoundMode.Down) -> Float
func avxSuperAdd(_ lhs: Float, _ rhs: Float, rounding: StaticRoundMode.ToNearestOrAwayFromZero) -> Float

avxSuperAdd(0.5, 0.5, rounding: .down)
1 Like

I am very not. Just because you can synthesize one feature from another doesn't mean they are the same feature.

Okay, but if you’re not a compiler engineer, what are you going to do with it as stated? The non failable URL initializer work fine with StaticString, and the other two examples so far need you to have your hands in the compiler for a successful implementation. I don’t dispute that it’s enough to have it, but in that form it feels more like an underscored attribute than a building block for compile-time evaluation.

It's important to note that there is a difference between strings that can be evaluated at compile-time, and string literals.

For the URL example, I mentioned in that thread why I don't think it's actually that great - it looks like compile-time validation, which is something people have asked for, but I feel it's almost dishonest because it isn't actually checking anything at compile-time. If you hit a branch you didn't test, and the URL isn't valid, your app will still crash, just like if you wrote it with the force-unwrap.

It's like if your daughter asks for a pony for Christmas, and you try to fool her by putting a wig on the dog.

Anyway, the justification is that since it's a string literal, you're less likely to make a mistake. If that was broadened to include all compile-time evaluable strings, the balance would change dramatically, and personally I'd be much more strongly against it. Compile-time evaluable functions can also be complex, so the justification that you're less likely to make a mistake when constructing a URL string doesn't hold up as well.

So yeah - compile-time evaluable strings are different to string literals. It's subtle, but for API designers trying to promote correct usage of their libraries, it can be an important difference.

1 Like

As Artem states in his initial post, this is very much an initial building block. Over time, the goal is to grow our compile-time capabilities to the point where users can define things like compile-time validation themselves, in Swift, without that code needing to live inside the compiler. So, URL would eventually generate a compile time error by running Swift code found in the Foundation package.

StaticString is not sufficient to enable this, because while the string must be static, which string is chosen can be determined at runtime i.e. URL(Bool.random() ? "https://valid.com/" : "invalid . com").

Foundation is ABI- and source-stable and so it's better to get these things right first time, rather than going with StaticString for now and switching to true constant argument later once we have the ability to make use of them to do compile-time validation. That's why this initial building block is useful now, to let people start to get their interfaces defined in a future-proof way.

4 Likes