[Pitch] Boolean Generics

Hello Swift Evolution,

Right now, with integer generics, we can represent fixed-size collections, but we don’t have any way of restricting the possible values to only a specific set. I would like to propose that we extend value generics to support Bool. Recently, I’ve run into several cases where these could dramatically reduce code duplication:

Static State
I was working on a wrapper for the Vulkan API and I needed to model a VkCommandBuffer. Command buffers have multiple states and only certain calls are valid in certain states. I wanted to explicitly block the use of functions that only work in the Recording state from being used in the Executable state, and the only way to do this currently in Swift is to model these as two different types:

// This is simplified a lot, and there is way more nuance to command buffers

struct RecordingCommandBuffer {
    let handle: VkCommandBuffer

    init() { … }
    deinit { deinit code }

    consuming func finishRecording() -> ExecutableCommandBuffer {
        let cb = ExecutableCommandBuffer(handle: handle) 
        discard self
        return cb
    }
}

struct ExecutableCommandBuffer {
    let handle: VkCommandBuffer

    fileprivate init(handle: VkCommandBuffer) { … }
    deinit { the same deinit code again }
}

// There are many more commands just like this
func cmdDraw(_ cb: inout RecordingCommandBuffer) { … }

// This would actually be a function on the Queue type
func submit(_ cb: consuming ExecutableCommandBuffer) { … }

With boolean generics, this could be modeled as a generic parameter, reducing code duplication and boilerplate:

struct CommandBuffer<let isExecutable: Bool> {
    let handle: VkCommandBuffer

    init() where isExecutable == false { … }
    deinit { deinit code }

    consuming func finishRecording() -> CommandBuffer<true> where isExecutable == false {
        let cb = CommandBuffer<true>(handle: handle)         discard self
        return cb
    }
}

// There are many more commands just like this
func cmdDraw(_ cb: inout CommandBuffer<false>) { … }

(This specific example would be improved by also having labels for generic parameters, but that is a pitch for another day.)

Mutability
Another big use case of this for me is for modeling mutability. In the Swift stdlib, there are multiple types now that have a mutable and an immutable variant with perfectly duplicated code and a few specific additions (Ref/MutableRef and Span/MutableSpan). I have also needed to make similar structures for my own libraries. For example, this simplified 1-D vector type that I made with a corresponding view type:

struct Vector: ~Copyable {
    ...

    func dot(_ other: borrowing Vector) -> Double { … }
    static func +=(lhs: inout Vector, rhs: borrowing Vector) { … }

    @lifetime(self)
    subscript(_ range: some RangeExpression) -> VectorView<false> { … }
    @lifetime(&self)
    subscript(mutating range: some RangeExpression) -> VectorView<true> { … }
}

struct VectorView<let mutable: Bool>: ~Escapable, ~Copyable {
    …

    func dot(_ other: borrowing VectorView) -> Double { … }
    static func +=(lhs: inout VectorView<true>, rhs: borrowing VectorView) { … }
}

extension VectorView: Copyable where mutable == false {}

Using this mutable parameter, we can remove the need for a mutable and immutable variant and the need to duplicate all of the non-mutating functions across both view types.

Future Directions:

Parameterize Ref and Span Mutability
In my last example, I omitted the implementation of VectorView, but it would be great if I could just use Span<Double> as the backing reference. The problem is that there is no way to pick Span or MutableSpan based on the value of the mutable parameter. To solve this, we could have Span be parameterized over its mutability and make MutableSpan just a type alias of Span with mutable set to true:

struct Span<Element, let mutable: Bool>: ~Escapable, ~Copyable { … }
extension Span: Copyable where mutable == false {}

typealias MutableSpan<Element> = Span<Element, mutable: true>

struct VectorView<let mutable: Bool>: ~Escapable, ~Copyable {
    var span: Span<Double, mutable: mutable>
    …
}

This would also be useful on Ref and MutableRef.

Conditionally Async Functions
I mentioned another future use-case in the thread on @Reasync. Instead of adding a new reasync modifier that works like rethrows, async can be parameterized over a boolean generic parameter. This would properly represent the way that the compiler must produce a specialization for both the async and non-async variations and could scale to more complicated cases with multiple closure arguments. For example:

func withSomeValue<T: ~Copyable, let isAsync: Bool, E: Error>(
     _ body: (SomeValue) async(isAsync) throws(E) -> T
) async(isAsync) throws(E) -> T {
    …
}

Enum Case Generic Values
In many cases, booleans will not have enough granularity to describe the API, so we could, in the future, allow user-defined enums as well without needing to add any complicated compile-time execution mechanism.

Boolean Expressions
Following the pitch for expressions of literals to be allowed in integer generics ([Pitch] Literal Expressions), the same concept could be implemented for boolean generics. This would allow || and && to be used in boolean generic parameter expressions. We could also allow integer operators that produce booleans like >, <, and ==.

6 Likes

Hmm, one concern I have is that all of the examples you give here would not just be improved, but would only be readable, with labels. CommandBuffer<true>, VectorView<true>, withSomeValue<A, B, true> are inscrutable at the use site. Contrast these with InlineArray<4, Int>, for which we would not want to label the argument even if we could.

So to me this would say that this pitch logically follows one for labeled generic parameters.

7 Likes

Yeah, that makes a lot of sense. I was actually already working on a pitch for labeled generic parameters. I just picked this one to pitch first because I knew how to go about the implementation for this one already.

The only issue I’m running into for that one is that the existing syntax for labeled parameters does not make sense with the syntax for value generics. My thought was that we could go the route of subscript parameter labels where there is no label by default and to add one you need to explicitly specify it. This would maintain source compatibility with the existing syntax without labels.

For type parameters, it could just be <label ParameterName: SomeProtocol> (Personally, I have no real use for type parameter labels, so I was planning to skip them unless someone else did).
For values though, there is already a let at the beginning where a label could go: <let mutable: Bool>. Adding the label before doesn’t work here.

Honestly, I had half a mind to just propose that we remove the let keyword altogether and making it synonymous with an empty label.

struct InlineArray<let count: Int, Element> { … }
// would be equivalent to
struct InlineArray<_ count: Int, Element> { … }

let could then be blocked on newly added value generic types other than Int.

1 Like

One thing that's interesting about all of these examples is that they only use the Boolean value as a constraint. The truthiness/falsiness value isn't actually used anywhere that would manifest at runtime.

That's different from how the integer generic parameter of InlineArray is used—that numeric value is important at runtime because it's a bound on various operations involving the array.

If you're just using the value as a constraint, then what this means is that you could just do that today without any language changes by using a "phantom types" approach:

enum Executable {}
enum Nonexecutable {}

struct CommandBuffer<Executability> {
  init() where Executability == Nonexecutable { … }

  consuming func finishRecording() -> CommandBuffer<Executable>
  where Executability == Nonexecutable {
    let cb = CommandBuffer<Executable>(handle: handle)
    discard self
    return cb
  }
}

func cmdDraw(_ cb: inout CommandBuffer<Nonexecutable>) { … }

This sort of fixes the "inscrutable at the usage site" issue that Xiaodi describes.

I'll fully admit that this isn't ideal—having to create phantom types like this is awkward boilerplate, and there's more boilerplate if you want to ensure that CommandBuffer's type argument can only support those two values. You'd have to define a protocol, constrain the phantom types to the protocol, and then use hacks to ensure that nobody else could conform to them externally.

I guess what I'm driving at is that Boolean generic parameters certainly have a future place in the language, but if the Boolean value is only used to make decisions about which APIs are available and does not factor into the executed logic of those APIs, then it might not be the right answer for what you're trying to design.

16 Likes

This, from Gemini, is a hallucination, but it represents my thoughts on the matter anyway. I didn't find the enum examples yet, but they probably exist on the forum.

In the specific thread you linked (Pitch: Boolean Generics), if you scroll through the replies, you will see a common refrain: "This should just be a two-case enum."

  • Look for posts by long-time contributors like Slava_Pestov or Douglas_Gregor, who often provide the "compiler-side" perspective on why enums are more complex to implement than integers but ultimately more desirable for the language.
1 Like

Ok, I realize that I probably need to explain what I am doing here at a larger scale.

I am a computer science student focusing in robotics, so I work with embedded systems, ML, and accelerators quite often. Right now I’m basically forced to use C++ for most things and Python where speed and reliability don’t matter. I would love to use Swift because the syntax is amazing, the experience of writing it is great (usually), and it could fill this gap perfectly of having a language that is both quite performant and very expressive. Currently, for a variety of reasons, Swift, sadly, does not fit here yet.

My goal here is to try to push Swift to a place where I can actually use it in projects. There’s many areas in Swift that just don’t cut it for these kinds of uses, but ignoring issues about unpredictability of performance, the current state of ~Copyable and lifetime dependencies, etc., the one that bars a lot of them is the state of generics. The value generics and compile-time execution story in Swift is pretty lacking. Right now, I have a list of a few initial things that I want to try to pitch and implement in some form or another that I personally have run into the need for:

  • Default Generic Parameter Values
  • Generic Parameter Labels
  • Value Generics with Primitives (Bool, other Int types, SIMD types for shape, etc.)
  • Value Generics with User-Defined Enums

This is ignoring general compile-time execution and custom structs because I know that is a long way away technically.

I’m not 100% sure what you are trying to get at here, but if I understand right, you are suggesting that I should use a two-case enum. The problem is that neither a boolean nor a two-case enum are allowed as generic parameters.

I see your point, but I don’t think this weakens the motivation at all though. This is a perfectly fine use of generics that can be found in other languages as well. The value needs to be part of the type here to properly statically check mutability (for my example). And, there is nothing stopping me from using them at runtime, I just haven’t needed this as often myself for booleans.

Thanks for the suggestion! I have been forced to use this before, but it’s just not a great experience for me, the author, or the user.

I'm not looking for a workaround or quick fix, I want to improve the language so we don’t have this problem anymore. Not just for this problem, but for all the issues I listed above.
And, I know Bool is probably not the most useful or obvious value generic type, the only reason I picked it to start with is because I wanted a smaller stepping stone initially.

Now that you mention it, I probably should reorder my pitches a bit. What I’ve found though is that most of these are very inter-dependent:

  • Booleans and other values may not be usable without parameter labels
  • Parameter labels, though, are really only useful for value generics or when you have default generic parameters

So should default generic parameters go first? If anybody has any advice on how to proceed with this or how it should be done, I would love to hear your thoughts.

1 Like

To further elaborate on this point, integer generics are only useful because InlineArray exists. You can instantiate an InlineArray with an integer generic parameter or integer literal, and its behavior then depends on the integer value.

So unless they’re paired with a new type whose behavior or layout depends on the boolean value somehow, Boolean (or other value typed) generics wouldn’t actually let you express anything new because the only thing you can ultimately do is ignore the Boolean value.

3 Likes

Since you mentioned C++ and compile-time computation, and since Swift is somewhat unusual in this regard, I feel obliged to point out that Swift only "instantiates"/"monomorphizes" generics as an optimization, or when compiling in Embedded mode—yes, even for InlineArray. The motivation for this is to support separately-compiled generic types and functions (like those in the stdlib and in e.g. SwiftUI). This means that generics give you behavior that is statically checked, but still formally does dynamic dispatch at run time. That may or may not be suitable for whatever you're trying to do with your various generics enhancements.

7 Likes

Personally, I think that this definition of generics is very limiting. It should not need to directly affect memory layout or behavior, and I think we need to generalize our understanding of generics (pun intended) to include compile-time constraints at the very least. There are way more completely valid uses of generics than just Array<MyType> that are demonstrated in many languages including Swift. One of the only languages I’ve used with generics that stops here is Java, which is not known for its incredibly expressive generics.
Plus, of my examples above (as @allevato mentioned), none affect memory layout, affect runtime behavior, or need any stdlib or other type to make them useful.

On the point of runtime behavior though, (again Bool is really not the best showing of this) the actual value of the parameter could be used to eliminate dead branches after specialization.
My favorite example of this in Swift is the stdlib’s Atomic<Value> type. On it, basically every function has an enum argument called ordering that describes the thread synchronization behavior. The atomics proposal (SE-0410) goes into great depth on how the best way to describe this is an enum passed as an argument, but the only way to make it reasonably performant is to add a special case in the compiler to stop you from passing values that aren’t known at compile-time using the @_const attribute.

Personally, I feel that the fact that we need an attribute like @_const is a clear failure on the part of the generics system. We needed to build a new, private construct, available only to the stdlib, to describe a parameter that is passed at compile time instead of just extending the generics system whose purpose is compile-time parameterization (whether just in the type system or actually through specialization). I think this would be a better way to describe the load() function:

func load<let ordering: AtomicLoadOrdering>() -> Value

func main() {
    let atomicValue = …
    atomicValue.load<ordering: .relaxed>()
}

Again, the only way to make this readable and usable is to make multiple changes to the generics system:

  • Add generic parameter labels
  • Allow labeled parameters to be manually specialized at the call site
  • Allow enums without associated values to be used in generics

(Side note: the proposal also mentions how they thought about using generics in the way @allevato suggested. But, a load ordering is semantically not a type, so to me it wouldn’t make sense to represent it as one. Not to mention that they had no guarantee that the compiler would actually specialize it to run efficiently.)

Side-ier note:
I would love to be able to express mathematical operations with value generics, but the current generic system just will not let me. For example, with convolutions, this would let me statically set a kernel size, whether or not to have biases, etc. at compile time and (hopefully) specialize for these cases so I don’t need to check the configuration on every iteration of a model:

typealias Size2d = InlineArray<2, Int>

struct Conv2d<let inChannels: Int, let outChannels: Int, let kernelSize: Size2d, let stride: Size2d, let enableBiases: Bool> {
    // This might not be perfectly right
    @const let parameterCount: Int = (kernelSize[0] * kernelSize[1] * inChannels  + Int(enableBiases)) * inChannels
}

Yeah, I learned this the hard way when I was first learning Swift a while ago. This is the main reason that I almost always use embedded mode, not because I’m running on bare metal or need a smaller memory footprint. I find that a lot of my code can get an order of magnitude faster at least just simply by enabling the embedded flag just because of proper specialization and cross module optimizations. This gives me a kind of disappointing choice:

  1. Use embedded mode for reasonable performance and predictability, but loose access to many of the interesting features of Swift (clean concurrency, existential values and errors, etc.)
  2. Or I can use full Swift with all the features, but have dramatically slower code with no way to predict how the program will perform, in large part because I don’t know what will be a direct, specialized call and what will be dynamic dispatch

I truly don’t understand why it needs to be this way, though. Just the fact that dynamic languages features exist in the language should not, in any way, affect the performance of code that does not even use them.
(Keep in mind that these sudden performance gains can be seen even in a project with a single executable target that only imports C libraries. I see no reason that generics, and even non-generic code, cannot be optimized just as well in the same module regardless of embedded mode)

Slight tangent:
Not to get too philosophical, but I think this points to a bigger structural problem in Swift: there is far too much focus on ABI and source stability. In a lot of evolution proposals, we sacrifice speed and even ergonomics very often just to maintain ABI and source stability across major and minor versions. I think this holds the language back a lot because we can rarely do something the “right way” because the ship on that feature sailed long ago when we set the precedent without thinking out the entire space. (I wish we could just queue up these breaking changes for the next major release instead of being entirely closed to the idea of changing things)

Now, I completely understand how important ABI stability is when you are writing OS APIs in Swift, but most people working on other platforms don’t need or want ABI stability or the artifacts of it. For most use cases, I don’t really care that the modules are separately-compiled, so a more generally useful approach might be to always specialize and only emit unspecialized versions of functions in resilient modules or when the user specifically asks for it (with something like @specialize(never)). To allow this, in a normal build, we could enable a sort of cross-module optimization where all function bodies are available at the SIL level to every consuming module for specialization.

As much as I’d like to say on this subject, it still doesn’t really impact the usefulness of this pitch or of broader value generics support in general.

(Sorry for the long post, but I didn’t really have anything that I felt I could remove)

3 Likes