Equality of functions

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.

Agreed. In fact, if it is not explicitly disallowed to have argument labels on callAsFunction methods, then we'd run into the following issue with function constraints that ignore labels:

struct Foo {
    func callAsFunction(bar: Int) -> Bool { return bar > 5 }
    func callAsFunction(baz: Int) -> Bool { return baz < 5 }
}

func call(f: (Int) -> Bool, x: Int) -> Bool {
    return f(x)
}

let y = Foo()

call(y, 5) // error: ambiguous conformance to (Int) -> Bool

I suppose that this isn't that problematic, though, since a client can always trivially disambiguate:

call(y.callAsFunction(bar:), 5)

Did I understand correctly that in your example, struct Foo does not declare conformance to (Int) -> (Int)?

Because that would be one more special thing about function protocols. Currently any other protocol requires conformance to be explicitly declared, even if type already satisfies all the requirements:

protocol Foo { func foo() }
struct FooImpl { func foo() {} }
let foo: Foo = FooImpl() // ERROR
1 Like

Ah good point, I didn't think about it from the angle of explicitly declaring conformance. Implementing callAsFunction does seem subtly different to me than implementing a method that happens to have the same name as something in a protocol, but I can see the argument for requiring struct Foo: (Int) -> Bool.

Then, if we wanted to allow labeled-argument callAsFunction definitions to conform, we'd need the declaration-site disambiguation syntax that has come up in the past (and IIRC already exists privately):

struct Foo: (Int) -> Bool {
    @satisfying((Int) -> Bool) func callAsFunction(bar: Int) -> Bool { ... }
}

Would @satisfying(<function type>) be applicable to any member function with a matching type signature? And if so, would that make callAsFunction redundant? In the world where conformance to function types is declared explicitly, I may be convinced that requiring no argument labels is the right solution.

Not really, IMO. p1 is a shorthand for referencing p1(x:), assuming there's no ambiguity, which is a value of type (Float) -> Float today, or in the future could be considered an anonymous type that conforms to any (Float) -> Float. Even this is something we've discussed deprecating, because it's led to confusing behavior in the past. At a fundamental level, argument labels are part of declaration names, not of types.

Another thing that I cannot get my head around, is the relation between argument and result types and the protocol. They don't look like associated types, because they are specified from the outside. To me they look more like generic parameters. But Swift currently does not have generic protocols, and, as far as I understood, is not planning to have.

Improving the UI of generics - #125 by anandabits mentions the following syntax, which may somewhat resolve this:

typealias CollectionOf<T> = Collection where Self.Element == T

Should we say that (Int) -> Void is a syntax sugar for Callable where Self.Args == (Int), Self.Result == Void? Or (Int) -> Void is a non-nominal protocol and notions of both generic args and associated types do not apply.

As an example, let's say we want to write a generic middleware that prints all the args and result of a function call.

With generic arguments, it would look something like this (variadic keyword is from swift-evolution/XXXX-variadic-generics.md at variadic-generics · technicated/swift-evolution · GitHub):

struct FunctionLogger<variadic Args, Result, F: (Arg) -> Result = any (Args) -> Result>: (Args) -> Result {
    var base: F

    func callAsFunction(_ args: Args) -> Result {
        print(args)...
        let result = base(args)
        print(result)
        return result
    }
}

And with associated types:

struct FunctionLogger<F: Callable>: Callbable {
    typealias Args = F.Args
    variadic typealias Result = F.Result

    var base: F

    func callAsFunction(_ args: Args) -> Result {
        print(args)...
        let result = base(args)
        print(result)
        return result
    }
}

Can the same struct conform to multiple function protocols? With generic arguments - yes, because (Int) -> Void and (Float) -> Void are different protocols. With associated types, there is only one protocol Callable and there can be only one conformance.

You can think of a function type constraint as being like a protocol with associated types, but I'm not sure that's the best way to model them, because functions have modifiers that aren't types, such as inout/owned/shared arguments, throws, and maybe eventually async, pure, or other effect modifiers. I would think of a function constraint as being an ad-hoc requirement that a type has a callAsFunction method with a matching signature.

2 Likes