Equality of functions

AnyHashable was added as a short term solution for properly bridging NSDictionary. Any boxes in general were avoided in favor of a better solution (like protocols could opt into having them auto generated) AFAIR.

2 Likes

Right, the long term solution is generalized existentials, which most recently received some discussion here: Improving the UI of generics.

Thanks!

Next question. This feature somewhat depends on structural types being able to conform to protocols. Otherwise captured values would be limited to nominal types and Array's and Optional's of equatable functions still would be non-equatable.

Do you think it has enough value to be implemented with those limitations or is it better to postpone it until conformance for structural types is there? Any timeline for the later?

If implemented with limitations, should it include some ad-hoc solutions for tuples, functions, other common structural types?

SwiftUI has this exact same issue. What it does is make a best effort to compare the function values (and all values, really) by memcmp, because all Swift types today currently guarantee that they're equivalent if they're bitwise-identical, tries to dynamically find Equatable conformance if that fails, and otherwise assumes it needs to update. That works well enough in our experience, and it will work even better over time as the compiler improves to reduce reabstractions and specialize away closures in more situations.

As far as long-term directions for equatable closures, I think a good approach would be to turn function types into generic constraints, like they are in Rust, and allow closures to be treated as anonymous types, which would allow:

func foo<T: Equatable & () -> ()>(_: T)

to not only take a closure with equatable context, but also unbox the context, which would be useful for performance to be able to store it inline in composed types.

17 Likes

When comparing by memcmp - do you compare function itself (2 words) or memory of its context (heap-allocated, variable length)?

Overall I like this approach a lot.

In my case I'm more interested in functions as existentials. So it would be something like func makeA(x: Int) -> Equatable & () -> Void or func makeA(x: Int) -> any Equatable & () -> Void, right?

Would extension any Equatable: Equatable automatically conform any Equatable & () -> Void to Equatable?

Would this affect ABI compatibility? Currently layout of the functions is pretty different from the layout of existential containers.

Would this also mean that functions become true structures and could be reflected? Not sure why would one need this, a bit of inertia after spending a lot of time on https://github.com/nickolas-pohilets/Runtime/blob/master/Sources/Runtime/Models/FunctionMirror.swift :smiley:

In the case of a struct that stores a function this will fall back to always updating (assuming a manual conformance to Equatable is not provided), right? The viral inability to provide a reasonable conformance is where I have run quite a few design challenges. In some cases, the potential false negatives are also a problem, particularly when you want to make test assertions that will be guaranteed to succeed.

I’ve been exploring using key paths in some use cases in order to reference code with stable identity. This obviously has the huge downside that it does not work with inline closure syntax and sometimes requires the existence of properties or subscripts that may not be desirable.

This is a great future direction that I would also really like to see. But it would still be great to have closures themselves provide conformances when possible (even when they escape the stack). It sounds like that is what you’re saying, correct?

but if we could instead compare some token identifying the code site, that would be a reasonable proxy.

This creates a compatibility hazard; it makes the identity, not observational behavior of functions as public API. For example, if I had a struct with a sorting function that was accessible to users, I cannot replace it with a faster sorting function without it being a potentially breaking change. (See also: https://www.hyrumslaw.com). If the function was initially defined in ObjC; then replacing it with a Swift implementation would be a breaking change (well, depends on how you define things...). I don't think these are reasonable outcomes.

1 Like

I guess by replacing you mean modifying source code, not changing in runtime, right?

This tokens are not designed to be serialised. They are valid only for one process. Most likely it will be an address of something, but I'm not 100% address of what should it be, so I'm using an abstract word 'token'.

That's similar to comparing ObjC classes by pointers. In the next run framework may be loaded at a different address and class might get a different address. But that does not stop you from using class pointers to check if two objects are of the same class.

1 Like

It only memcmps the inline storage of view structs. SwiftUI would certainly like to also look into closure contexts (though, even more, they'd like the out-of-line closure allocation to go away entirely), but there's no way to do so with the runtime information closures have now. The ABI for function values is intentionally general—the only guarantees are that the invocation function is a valid function pointer, and that for escaping closures the context word can be passed to swift_retain and swift_release. Any other details of what the context points to are a private contract between the invocation function and the context object. It is not necessarily even a valid pointer, because swift_retain on some platforms recognizes certain input values as obviously invalid and ignores them, and when it is, it isn't necessarily a pointer to something that's specifically a closure context, since a closure capturing a single refcounted value will directly adopt that one pointer as its context value.

Within this ABI, we could still add functionality to make it possible for some closure contexts to be deeply memcmp'ed, by having the compiler emit them with a new MetadataKind that guarantees that there is size information stored in the metadata somewhere that the runtime can find it. Maybe the runtime could then provide a runtime function that conservatively compares two closure values, by memcmp-ing the function values themselves, then if the context pointer is not an ignored-by-swift_retain value and therefore a valid pointer, checking the metadata of the pointers to see if they're one of these new kinds with size information, and then memcmp-ing the contexts if they are. That would probably be best vended from the runtime itself since it would rely on platform-specific details of the runtime implementation.

In this world with generic function constraints, it should be possible to make it so today's function types also serve as the any (...) -> T existential type, since the closure representation can accommodate all the information it needs—the invocation function is effectively the witness for the one callAsFunction requirement. Composition types like Equatable & () -> Void don't exist today, so they don't have any preexisting ABI compatibility concerns, so they ought to be able to use the same implementation as other existential types. This is similar to how Error works, where the Error type itself uses a specialized representation to be able to toll-free-bridge with NSError, but compositions like Error & SomeOtherProtocol use the normal representation.

Sure, the types that closures-as-generic-parameters would form seem like they ought to be as reflectable as other types are. The closure contexts that are generated today are not, really, because there's no ABI guarantee for what the compiler may generate in the future.

9 Likes

I guess by replacing you mean modifying source code, not changing in runtime, right?

Yes.

This tokens are not designed to be serialised. They are valid only for one process. Most likely it will be an address of something, but I'm not 100% address of what should it be, so I'm using an abstract word 'token'.

You can have the same issue without serialization. All you need is two sources for functions -- they might return literally the same function in one case, and a change to the source code means they now return different functions (even if semantically they're the same).


Apart from the compatibility hazard, I think the problem is one of semantics. As @alikvovk suggested, I think === is the better fit here.

From the docs:

This operator tests whether two instances have the same identity, not the same value. For value equality, see the equal-to operator ( == ) and the Equatable protocol.

Adding a "built-in overload" for === sounds a lot closer to what you want (identity) rather than adding a conformance for Equatable.

2 Likes

Let's consider the example, I was providing above:

func makeA(x: Int) -> () -> Void {
    return { print(x + 1) }
}
func makeB(x: Int) -> () -> Void {
    return { print(x + 1) }
}

When looking at this code, what I actually see, is this:

protocol CallableWithNoArgsReturnVoid {
    func call() -> Void
}

func makeA(x: Int) -> CallableWithNoArgsReturnVoid {
    struct Func: CallableWithNoArgsReturnVoid {
        let x: Int

        func call() -> Void {
            print(self.x + 1)
        }
    }
    return Func(x: x)
}
func makeB(x: Int) -> CallableWithNoArgsReturnVoid {
    struct Func: CallableWithNoArgsReturnVoid {
        let x: Int

        func call() -> Void {
            print(self.x + 1)
        }
    }
}

Operator === does not make sense for structs because structs don't really have identity. What has identity - is a type of the struct. Here you probably could compare makeA.Func.self as CallableWithNoArgsReturnVoid.Type against makeB.Func.self as CallableWithNoArgsReturnVoid.Type using ===. But that's not what we are comparing when we compare functions. We are comparing entire struct, and its type is only part of the information carried by the struct.

1 Like

That's a bit offtopic, but IMHO, separation between == and === is a hack which exists only to support interoperability with ObjC classes with value semantics.

Equality makes sense only for values, and class instances are not values, only references to them are. If heap allocated data is used to implement a value type, then it should be a struct which uses some private class as its implementation detail, and equality should be defined on a struct, not on a class.

Swift already bridges NSString, NSArray, NSDictionary and many other ObjC classes as structs. And probably in the future this will happen to even more classes - NSAttributedString, UIColor, NSParagraphStyle look like good candidates. If we extrapolate this, then we will found ourselves in a world where there are no classes which use == - only wrapper structs.

And if we don't use == for classes, only === - let's keep things simple and just merge them together.

That's what I would do if I were making Swift from scratch, but that's not what I am doing, so I'm gonna shut up here :slightly_smiling_face:

Well, setting aside the == vs === thing, I do think there's still an interesting distinction between the "semantically equal" that == provides vs. "absolutely identical", which is a property that can be tested with memcmp (though we don't directly expose it). For many use cases, including the React-ish framework case, being able to perform the latter as a heuristic guess without the full dynamic, arbitrary-code-execution abilities of Equatable is nice. I don't think === is necessarily the best way to spell it, though, since it's a more niche use case and probably deserves a named function.

1 Like

@Joe_Groff, if from type-checker perspective function type is a normal protocol, which defines a single method, then I would expect to be able to conform a had-written struct to that protocol. Probably that method would be called callAsFunction for consistency with swift-evolution/0253-callable.md at master Ā· apple/swift-evolution Ā· GitHub.

Currently, when checking protocol conformance, type checker (or whatever that compiler component is), also requires argument labels to match.

What would be argument labels of that single protocol method? My first guess would be anonymous - without argument labels. If type implements callAsFunction with arguments labels, then it would not conform to the protocol type, which is logical but still a bit surprising - for regular functions argument labels do not matter.

struct Polynomial: (Float) -> Float {
    let coefficients: [Float]

    // Does this method satisfy conformance to (Float) -> Float ?
    func callAsFunction(x: Float) -> Float {
        ...
    }
}

Thank you, this is a really nice blog post.

Yeah, it would make sense to me to be able to declare a type as conforming to a function constraint if it has a matching callAsFunction method. Function types don't have argument labels, so it makes sense to me that the matching callAsFunction would have to use unlabeled arguments.

This makes callable structs behave different from regular functions.

func p1(x: Float) -> Float { return x * x + 2 * x + 1 }
let y1 = p1(x: 3.141) // Called with argument label
let f1: (Float) -> Float = p1 // OK
let p2 = Polynomial(coefficients: [1, 2, 1])
let y2 = p2(x: 3.141) // Called with argument label
let f2: (Float) -> Float = p2 // Struct Polynomial fails to conform to (Float) -> Float because of argument label

That's a bit confusing, but I cannot really suggest anything better. Possible workaround would be to provide an overload for callAsFunction that has no argument labels:

struct Polynomial: (Float) -> Float {
    let coefficients: [Float]

    // Can be called with argument labels, but does not satisfy conformance to (Float) -> Float
    func callAsFunction(x: Float) -> Float {
        ...
    }

    // Exists only to satisfy (Float) -> Float
    // Pollutes namespace of the overloads
    func callAsFunction(_ x: Float) -> Float {
        return self.callAsFunction(x: x)
    }
}

Providing extra overload pollutes space of the overloads

let p = Polynomial(coefficients: [1, 2, 1])
p(
// And now IDE suggests me two possible overloads - (x: Float) -> Float and (_: Float) -> Float
// Which one should I choose?

All of this is not a problem if callbable struct has no argument labels in the first place. I don't think they should be banned, but would be interesting to see some data on how people are actually using callable structs.

@Joe_Groff, do you think this is something to be concerned or am I nitpicking?

Argument labels usually require the base name of a method for context in order to be sensible. That is one of the reasons (although not the most important) that they were removed from function types.

In order to use labels effectively with callAsFunction they should make sense regardless of the name of the variable that is invoked as a function. This is a pretty tall order. I don't recall all of the details from the callAsFunction discussions anymore, but it may have even made sense to disallow labels altogether on callAsFunction.

In the example above, what value does the x label add? Why not just provide a single callAsFunction with no label?

I agree with @Nickolas_Pohilets. After the effort that was made to remove the significance of argument labels from the type system, I feel like it would be somewhat broken for a hypothetical feature of function-type constraints to reintroduce that significance, albeit in a slightly different manner.

I think there's a reasonable argument to be made that since a function with argument labels can be converted to a "function existential" (i.e. Swift's current function types) such a function should also be able to fulfill a function constraint. However, even if this is allowed it will often be inadvisable to use labels on callAsFunction.