[Pitch #2] Add `isKnownIdentical` Method for Quick Comparisons to `Equatable`

Hi! Here is a new pitch refined from an earlier one:

Add isKnownIdentical Method for Quick Comparisons to Equatable

Introduction

We propose a new isKnownIdentical method to Equatable for determining in constant-time if two instances must be equal by-value.

Motivation

Suppose we have some code that listens to elements from an AsyncSequence. Every element received from the AsyncSequence is then used to perform some work that scales linearly with the size of the element:

func doLinearOperation<T>(with element: T) {
  //  perform some operation
  //  scales linearly with T
}

func f1<S>(sequence: S) async throws
where S: AsyncSequence {
  for try await element in sequence {
    doLinearOperation(with: element)
  }
}

Suppose we know that doLinearOperation only performs important work when element is not equal to the last value (here we define “equal” to imply “value equality”). The first call to doLinearOperation is important, and the next calls to doLinearOperation are only important if element is not equal by-value to the last element that was used to perform doLinearOperation.

If we know that Element conforms to Equatable, we can choose to “memoize” our values before we perform doLinearOperation:

func f2<S>(sequence: S) async throws
where S: AsyncSequence, S.Element: Equatable {
  var oldElement: S.Element?
  for try await element in sequence {
    if oldElement == element { continue }
    oldElement = element
    doLinearOperation(with: element)
  }
}

When our sequence produces many elements that are equal by-value, “eagerly” passing that element to doLinearOperation performs more work than necessary. Performing a check for value-equality before we pass that element to doLinearOperation saves us the work from performing doLinearOperation more than necessary, but we have now traded performance in a different direction. Because we know that the work performed in doLinearOperation scales linearly with the size of the element, and we know that the == operator also scales linearly with the size of the element, we now perform two linear operations whenever our sequence delivers a new element that is not equal by-value to the previous input to doLinearOperation.

At this point our product engineer has to make a tradeoff: do we “eagerly” perform the call to doLinearOperation without a preflight check for value equality on the expectation that sequence will produce many non-equal values, or do we perform the call to doLinearOperation with a preflight check for value equality on the expectation that sequence will produce many equal values?

There is a third path forward… a “quick” check against elements that returns in constant-time and guarantees these instances must be equal by value.

Prior Art

Swift.String already ships a public-but-underscored API that returns in constant time:[1]

extension String {
  /// Returns a boolean value indicating whether this string is identical to
  /// `other`.
  ///
  /// Two string values are identical if there is no way to distinguish between
  /// them.
  ///
  /// Comparing strings this way includes comparing (normally) hidden
  /// implementation details such as the memory location of any underlying
  /// string storage object. Therefore, identical strings are guaranteed to
  /// compare equal with `==`, but not all equal strings are considered
  /// identical.
  ///
  /// - Performance: O(1)
  @_alwaysEmitIntoClient
  public func _isIdentical(to other: Self) -> Bool {
    self._guts.rawBits == other._guts.rawBits
  }
}

We don’t see this API currently being used in standard library, but it’s possible this API is already being used to optimize performance in private frameworks from Apple.

Many more examples of isIdentical functions are currently shipping in Swift-Collections[2][3][4][5][6][7][8][9][10][11][12][13], Swift-Markdown[14], and Swift-CowBox[15]. We also support isIdentical on the upcoming Span and RawSpan types from Standard Library.[16]

Proposed Solution

Many types in Standard Library are “copy-on-write” data structures. These types present as value types, but can leverage a reference to some shared state to optimize for performance. When we copy this value we copy a reference to shared storage. If we perform a mutation on a copy we can preserve value semantics by copying the storage reference to a unique value before we write our mutation: we “copy” on “write”.

This means that many types in Standard Library already have some private reference that can be checked in constant-time to determine if two values are identical. Because these types copy before writing, two values that are identical by their shared storage must be equal by value.

Suppose our Equatable protocol adopts a method that can return in constant time if two instances are identical and must be equal by-value. We can now refactor our operation on AsyncSequence to:

func f3<S>(sequence: S) async throws
where S: AsyncSequence, S.Element: Equatable {
  var oldElement: S.Element?
  for try await element in sequence {
    if oldElement?.isKnownIdentical(to: element) ?? false { continue }
    oldElement = element
    doLinearOperation(with: element)
  }
}

What has this done for our performance? We know that doLinearOperation performs a linear operation over element. We also know that isKnownIdentical returns in constant-time. If isKnownIdentical returns true we skip performing doLinearOperation. If isIdentical returns false or nil we perform doLinearOperation, but this is now one linear operation. We will potentially perform this linear operation even if the element returned is equal by-value, but since the preflight check to confirm value equality was itself a linear operation, we now perform one linear operation instead of two.

Detailed Design

Here is a new method defined on Equatable:

public protocol Equatable {
  // The original requirement is unchanged.
  static func == (lhs: Self, rhs: Self) -> Bool
  
  // Returns if `self` can be quickly determined to be identical to `other`.
  //
  // - A `nil` result indicates that the type does not implement a fast test for
  //   this condition, and that it only provides the full `==` implementation.
  // - A `true` result indicates that the two values are definitely identical
  //   (for example, they might share their hidden reference to the same
  //   storage representation). By reflexivity, `==` is guaranteed to return
  //   `true` in this case.
  // - A `false` result indicates that the two values aren't identical. Their
  //   contents may or may not still compare equal in this case.
  //
  // Complexity: O(1).
  @available(SwiftStdlib 6.3, *)
  func isKnownIdentical(to other: Self) -> Bool?
}

@available(SwiftStdlib 6.3, *)
extension Equatable {
  @available(SwiftStdlib 6.3, *)
  func isKnownIdentical(to other: Self) -> Bool? { nil }
}

We add isKnownIdentical to all types that adopt Equatable, but types that adopt Equatable choose to “opt-in” with their own custom implementation of isKnownIdentical. By default, all types return nil to indicate this type does not have the ability to make any decision about identity equality.

If a type does have some ability to quickly test for identity equality, this type can return true or false from isKnownIdentical. Here is an example from String:

extension String {
  func isKnownIdentical(to other: Self) -> Bool? {
    self._isIdentical(to: other)
  }
}

Here is an example of a copy-on-write data structure that manages some private storage property for structural sharing:

extension CowBox {
  func isKnownIdentical(to other: Self) -> Bool? {
    self._storage === other._storage
  }
}

Source Compatibility

Adding a new requirement to an existing protocol is source breaking if that new requirement uses Self and that new requirement is the first use of Self. Because our existing == operator on Equatable used Self, this proposal is safe for source compatibility.

Impact on ABI

Adding a new requirement to an existing protocol is ABI breaking if we do not include an unconstrained default implementation. Because we include a default implementation of isKnownIdentical, this proposal is safe for ABI compatibility.

Alternatives Considered

New Distinguishable protocol

The original version of this pitch suggested a new protocol independent of Equatable:

protocol Distinguishable {
  func isKnownIdentical(to other: Self) -> Bool?
}

Algorithms from generic contexts that operated on Distinguishable could then use isKnownIdentical to optimize performance:

func f4<S>(sequence: S) async throws
where S: AsyncSequence, S.Element: Distinguishable {
  var oldElement: S.Element?
  for try await element in sequence {
    if oldElement?.isKnownIdentical(to: element) ?? false { continue }
    oldElement = element
    doLinearOperation(with: element)
  }
}

This is good… but let’s think about what happens if the element returned by sequence might not always be Distinguishable. We can assume the element will always be Equatable, but we have to “code around” Distinguishable:

func f2<S>(sequence: S) async throws
where S: AsyncSequence, S.Element: Equatable {
  var oldElement: S.Element?
  for try await element in sequence {
    if oldElement == element { continue }
    oldElement = element
    doLinearOperation(with: element)
  }
}

func f4<S>(sequence: S) async throws
where S: AsyncSequence, S.Element: Distinguishable {
  var oldElement: S.Element?
  for try await element in sequence {
    if oldElement?.isKnownIdentical(to: element) ?? false { continue }
    oldElement = element
    doLinearOperation(with: element)
  }
}

func f5<S>(sequence: S) async throws
where S: AsyncSequence, S.Element: Distinguishable, S.Element: Equatable {
  var oldElement: S.Element?
  for try await element in sequence {
    if oldElement?.isKnownIdentical(to: element) ?? false { continue }
    oldElement = element
    doLinearOperation(with: element)
  }
}

We now need three different specializations:

  • One for a type that is Equatable and not Distinguishable.
  • One for a type that is Distinguishable and not Equatable.
  • One for a type that is Equatable and Distinguishable.

A Distinguishable protocol would offer a lot of flexibility: product engineers could define types (such as Span and RawSpan) that have the ability to return a meaningful answer to isKnownIdentical without adopting Equatable. The trouble is that the price we pay for that extra flexibility is much more extra ceremony to support a new generic context specialization when we expect most engineers want to use isKnownIdentical in place of value equality.

Overload for ===

Could we “overload” the === operator from AnyObject? This proposal considers that question to be orthogonal to our goal of exposing identity equality with the isKnownIdentical method. We could choose to overload ===, but this would be a larger “conceptual” and “philosophical” change because the === operator is currently meant for AnyObject types — not value types like String and Array.

Overload for Optionals

When working with Optional values we can add the following overload:

@available(SwiftStdlib 6.3, *)
extension Optional {
  @available(SwiftStdlib 6.3, *)
  public func isKnownIdentical(to other: Self) -> Bool?
  where Wrapped: Equatable {
    switch (self, other) {
    case let (value?, other?):
      return value.isKnownIdentical(to: other)
    case (nil, nil):
      return true
    default:
      return false
    }
  }
}

Because this overload needs no private or internal symbols from Standard Library, we can omit this overload from our proposal. Product engineers that want this overload can choose to implement it for themselves.

Alternative Semantics

Instead of publishing an isKnownIdentical function which implies two types must be equal, could we think of things from the opposite direction? Could we publish a maybeDifferent function which implies two types might not be equal? This then introduces some potential ambiguity for product engineers: to what extent does “maybe different” imply “probably different”? This ambiguity could be settled with extra documentation on the protocol, but isKnownIdentical solves that ambiguity up-front. The isKnownIdentical function is also consistent with the prior art in this space.

In the same way this proposal exposes a way to quickly check if two Equatable values must be equal, product engineers might want a way to quickly check if two Equatable values must not be equal. This is an interesting idea, but this can exist as an independent proposal. We don’t need to block the review of this proposal on a review of isKnownNotIdentical semantics.

Acknowledgments

Thanks @dnadoba for suggesting the isKnownIdentical function should exist on a protocol.

Thanks @Ben_Cohen for helping to think through and generalize the original use-case and problem-statement.

Thanks @Slava_Pestov for helping to investigate source-compatibility and ABI implications of a new requirement on an existing protocol.


  1. swift/stdlib/public/core/String.swift at swift-6.1-RELEASE · swiftlang/swift · GitHub ↩︎

  2. swift-collections/Sources/DequeModule/Deque._Storage.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  3. swift-collections/Sources/HashTreeCollections/HashNode/_HashNode.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  4. swift-collections/Sources/HashTreeCollections/HashNode/_RawHashNode.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  5. swift-collections/Sources/RopeModule/BigString/Conformances/BigString+Equatable.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  6. swift-collections/Sources/RopeModule/BigString/Views/BigString+UnicodeScalarView.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  7. swift-collections/Sources/RopeModule/BigString/Views/BigString+UTF8View.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  8. swift-collections/Sources/RopeModule/BigString/Views/BigString+UTF16View.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  9. swift-collections/Sources/RopeModule/BigString/Views/BigSubstring.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  10. swift-collections/Sources/RopeModule/BigString/Views/BigSubstring+UnicodeScalarView.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  11. swift-collections/Sources/RopeModule/BigString/Views/BigSubstring+UTF8View.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  12. swift-collections/Sources/RopeModule/BigString/Views/BigSubstring+UTF16View.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  13. swift-collections/Sources/RopeModule/Rope/Basics/Rope.swift at 1.2.0 · apple/swift-collections · GitHub ↩︎

  14. swift-markdown/Sources/Markdown/Base/Markup.swift at swift-6.1.1-RELEASE · swiftlang/swift-markdown · GitHub ↩︎

  15. Swift-CowBox/Sources/CowBox/CowBox.swift at 1.1.0 · Swift-CowBox/Swift-CowBox · GitHub ↩︎

  16. swift-evolution/proposals/0447-span-access-shared-contiguous-storage.md at main · swiftlang/swift-evolution · GitHub ↩︎

5 Likes

How many different implementations of isKnownIdentical do we think there can realistically be? There is a universal implementation that works for all native Copyable Swift types, which is to memcmp the contents of two values of the same type. One possible refinement to that implementation might be to also mask out insignificant bits and padding bytes, but that could potentially be done by deriving the padding bytes from type metadata like SwiftUI does. Having this as a concrete implementation instead of a protocol requirement would mean that it can be used efficiently in generic contexts and in bulk over arrays of values without having to make an indirect call to a protocol implementation for every element.

5 Likes

It might be worth having that implementation factored out into a helper function with a relatively type-agnostic signature so that implementations can opt to use it as a one-liner. Perhaps something like:

func areKnownIdentical<T>(_ lhs: borrowing T, _ rhs: borrowing T) -> Bool where T: ~Copyable & ~Escapable

Naming is hard. Still, this isn't something we want folks to reach for by reflex (they should use the protocol member), so we might want to give it a slightly scarier name?

5 Likes

I would favor a function as well as it allows overloading with optional T on either side:

1 Like

"Great minds" and all. :slight_smile:

2 Likes

I have an independent pitch for adding identity equality methods to concrete types from standard library:

  • String
  • Substring
  • Array
  • ArraySlice
  • ContiguousArray
  • Dictionary
  • Set

There could be more… but I think these would be the best candidates for a first-pass.

And then there are types from Foundation, Swift-Collections, and any custom types from product engineers that want to expose their own custom idea of identity equality.

For the most part I think this can get us most of the way there… but I also believe there might be situations that we would need more code to defend against.

There are two important goals we want for a method to determine identity equality:

  1. Returns true to imply values must be equal by value.
  2. Returns in constant-time without a linear-time check for value equality.

Let's take a closer look at number one and see what might hurt us with memcmp. Here is an example of an Element that we might want to check for identity equality using memcmp:

final class Storage {
  var x = 0
  var y = 0
}

struct Element: Equatable {
  private let storage = Storage()
  
  static func == (lhs: Element, rhs: Element) -> Bool {
    lhs.storage.x == rhs.storage.x &&
    lhs.storage.y == rhs.storage.y
  }
}

And here is where we might receive these Element values:

func isKnownIdentical<T>(_ lhs: T, _ rhs: T) -> Bool {
  //  TODO: memcmp
}

func f4<S>(sequence: S) async throws
where S: AsyncSequence, S.Element == Element {
  var oldElement: S.Element?
  for try await element in sequence {
    if isKnownIdentical(oldElement, element) { continue }
    oldElement = element
    doLinearOperation(with: element)
  }
}

Keep in mind our sequence is asynchronous and we receive these Element values over time. Because our Storage makes no guarantees of immutability… we could receive an Element value at time T(1) that compares as "identical" to an Element value at time T(0)… but these two values do not compare equal by value: the underlying Storage has mutated underneath us. The memcmp did what it was supposed to: it compared the bytes of Element and returned true when those bytes were equal. But we lost our important goal number one: returning true for identity equality implies these instances must return true for value equality.

Actually… nevermind… that wasn't a good example.

Let's take a closer look at number two and see what might hurt us with memcmp. Here is an example of another Element:[1]

struct Element: Equatable {
  // A struct with about 80 bytes
  let a: Int64 = 0
  let b: Int64 = 0
  let c: Int64 = 0
  let d: Int64 = 0
  let e: Int64 = 0
  let f: Int64 = 0
  let g: Int64 = 0
  let h: Int64 = 0
  let i: Int64 = 0
  let j: Int64 = 0
}

The cost of a check for value equality is 80 bytes. The cost of a check for identity equality using memcmp is… also 80 bytes. At this point we have lost our important goal number two: we return from our identity equality check in constant time without a linear time check for value equality. If this Element was built on top of some kind of copy-on-write storage then we can assume identity equality would be equal to the cost of checking just one pointer: 8 bytes on a 64 bit architecture.

I think the question of whether or not identity equality ships as a method on Equatable or as a "free" function is important and I do look forward to discussing pros and cons. But if we did implement a free function… my opinion right this moment is we don't actually want memcmp as a default. What we want as a default is nil:

func isKnownIdentical<T>(_ lhs: T, _ rhs: T) -> Bool? {
  nil
}

The nil here is a signal to the product engineer that this specific type has no ability to determine a concept of identity equality.

And then library maintainers can override this with a concrete implementation on types that can return some meaningful answer for identity equality:

func isKnownIdentical(_ lhs: String, _ rhs: String) -> Bool? {
  lhs._isIdentical(to: rhs)
}

I think this should work as an alternative to a method on Equatable… the compiler should choose for us the correct override at compile time AFAIK.


  1. Swift’s Copy-on-write Optimisation | Jared Khan ↩︎

Could that not be supported on a method with the snippet from the pitch?

@available(SwiftStdlib 6.3, *)
extension Optional {
  @available(SwiftStdlib 6.3, *)
  public func isKnownIdentical(to other: Self) -> Bool?
  where Wrapped: Equatable {
    switch (self, other) {
    case let (value?, other?):
      return value.isKnownIdentical(to: other)
    case (nil, nil):
      return true
    default:
      return false
    }
  }
}

If the goal is to expose a first-pass "fast" operation, this would be already utterable in Swift 6.2 on concrete types (modulo any optimization bugs) as span.isIdentical(to: other.span).

I think there's an argument to be made that noncopyable types ought to have === just like class types; if we were to accept that argument (not mandatory), the idiomatic spelling for this operation on any "Spannable" type could then be span === other.span.

We don't have the protocol hierarchy to identify "Spannable" types yet, but that seems like it will (or can, if we keep it in mind) fall out of the inevitable work ahead of us of creating a container hierarchy that accommodates noncopyable types.

I think this all composes very nicely and spares us the need for additional dedicated protocol requirements or APIs, or to introduce a named concept of "identity equality" to copyable types in an already overloaded (ha) space which has ==, ===, Identifiable, etc.

7 Likes

I feel uneasy about adding methods to Optional. Optionals are mostly interacted with using overloads, operators, and syntax sugar in Swift. Correctly, every time I use map on an Optional feels like I am making the code more difficult to read for future me.

For example, an Int does not have a map method, so if I read something like counter.map { $0 + set.count } I get that counter is actually an Int?. But String, Array, etc. also have map methods and now when I am bug hunting I am reading such code much more slowly, checking if (…).map { … } should have been (…)?.map { … }.

isKnownIdentical falls even more so into this trap since every optional type for which (…).isKnownIdentical(to: …) is valid code, so too is (…)?.isKnownIdentical(to: …).

The later case ((…)?.isKnownIdentical(to: …)) may also be typed accidentally. This returns nil if the lhs is nil, even when the rhs might also be nil and thus the return value should be true. A freestanding function doesn’t suffer from this confusing call site.

If I understand Span correctly, this only works for contiguous memory, not necessarily a Dictionary or some tree structure which cannot provide a span view.

4 Likes

If what you are suggesting is that checking for identity equality can exist independently of the Equatable protocol this was part of the original pitch and is currently an "Alternative Considered". One reason we kept this inside Equatable is to prevent against needing extra generic specialization overloads. Where a product engineer needs:

  • One generic specialization for a type that is Equatable and is Spannable.
  • One generic specialization for a type that is Equatable and is not Spannable.
  • One generic specialization for a type that is not Equatable and is Spannable.

Since we expect product engineers want isKnownIdentical in-place of (or accompanying) the result of a == value operator we think keeping that in Equatable is the better trade off for now.

Just to help me understand the feedback on that… are you suggesting that the pitch could be refined to expose a public and official-slash-supported "free" function in addition to our idea that a member method could be added to either an existing protocol like Equatable or a new protocol? I'm not opposed to this idea right this moment and would like to think it through… but I also wanted to understand what direction you were thinking about that. Thanks!

Hmm… what would your thoughts be about a static member method on Equatable as an alternative to an instance method?

protocol Equatable {
  static func isKnownIdentical(lhs: Self, rhs: Self) -> Bool?
}

extension Equatable {
  static func isKnownIdentical(lhs: Self, rhs: Self) -> Bool? { nil }
}

extension String {
  static func isKnownIdentical(lhs: Self, rhs: Self) -> Bool? {
    lhs._isIdentical(to: rhs)
  }
}

@main
struct MyExecutable {
  static func main() {
    let s = "Hello, world!"
    print(String.isKnownIdentical(lhs: s, rhs: s))
  }
}

Could you please help me understand if this could give us a better way of handling optional values?

I prefer this over the method, but it’s also redundant as the type is already specified by the parameters. So, instead of

if String.isKnownIdentical(lhs: s, rhs: s) { … }

I would spell it

if isKnownIdentical(s, s) { … }

similar to how == is defined and called without invoking the type explicitly.

2 Likes

Hmm… for some reason this fails to compile from 6.2:

protocol Equatable {
  static func isKnownIdentical(_ lhs: Self, _ rhs: Self) -> Bool?
}

extension Equatable {
  static func isKnownIdentical(_ lhs: Self, _ rhs: Self) -> Bool? { nil }
}

extension String {
  static func isKnownIdentical(_ lhs: Self, _ rhs: Self) -> Bool? {
    lhs._isIdentical(to: rhs)
  }
}

@main
struct MyExecutable {
  static func main() {
    let s = "Hello, world!"
    if isKnownIdentical(s, s) ?? false {
//     ^ error: Cannot find 'isKnownIdentical' in scope
      print("true")
    }
  }
}

Here is a demo setup. I introduce a new protocol DemoEquatable to simulate Equatable but with an added requirement:

// simulation of `Equatable` but with `areKnownIdentical` as requirement
protocol DemoEquatable: Equatable {
	static func areKnownIdentical(_ lhs: Self, _ rhs: Self) -> Bool?
}

extension DemoEquatable {
	static func areKnownIdentical(_ lhs: Self, _ rhs: Self) -> Bool? { nil }
}

// have String and Int partake in simulation of future `Equatable`
extension String: DemoEquatable {
	static func areKnownIdentical(_ lhs: Self, _ rhs: Self) -> Bool? {
		lhs._isIdentical(to: rhs)
	}
}

extension Int: DemoEquatable {}

// freestanding functions
func areKnownIdentical<T: DemoEquatable>(_ lhs: T, _ rhs: T) -> Bool? {
	T.areKnownIdentical(lhs, rhs)
}

func areKnownIdentical<T: DemoEquatable>(_ lhs: T?, _ rhs: T?) -> Bool? {
	switch (lhs, rhs) {
	case (nil, nil):
		return true
	case (let lhs?, let rhs?):
		return T.areKnownIdentical(lhs, rhs)
	default:
		return false
	}
}

// test
let s1 = "abc"
let s2 = "xyz"
let s3: String? = nil

print("String:")
print(areKnownIdentical(s1, s1))
print(areKnownIdentical(s1, nil))
print(areKnownIdentical(nil, s1))
print(areKnownIdentical(s3, s3))
print(areKnownIdentical(s1, s2))

let i1 = 5
let i2 = 9
let i3: Int? = nil

print("\nInt:")
print(areKnownIdentical(i1, i1))
print(areKnownIdentical(i1, nil))
print(areKnownIdentical(nil, i1))
print(areKnownIdentical(i3, i3))
print(areKnownIdentical(i1, i2))

The code above prints:

String:
Optional(true)
Optional(false)
Optional(false)
Optional(true)
Optional(false)

Int:
nil
Optional(false)
Optional(false)
Optional(true)
nil
2 Likes

Hmm… so suppose we did ship a "free" function… but we also ship an instance member method on Equatable:

protocol DemoEquatable: Equatable {
  func isKnownIdentical(to other: Self) -> Bool?
}

extension DemoEquatable {
  func isKnownIdentical(to other: Self) -> Bool? { nil }
}

extension String: DemoEquatable {
  func isKnownIdentical(to other: Self) -> Bool? {
    self._isIdentical(to: other)
  }
}

extension Int: DemoEquatable {}

func areKnownIdentical<T: DemoEquatable>(_ lhs: T, _ rhs: T) -> Bool? {
  lhs.isKnownIdentical(to: rhs)
}

func areKnownIdentical<T: DemoEquatable>(_ lhs: T?, _ rhs: T?) -> Bool? {
  switch (lhs, rhs) {
  case (nil, nil):
    return true
  case (let lhs?, let rhs?):
    return lhs.isKnownIdentical(to: rhs)
  default:
    return false
  }
}

@main
struct MyExecutable {
  static func main() {
    // test
    let s1 = "abc"
    let s2 = "xyz"
    let s3: String? = nil

    print("String:")
    print(areKnownIdentical(s1, s1))
    print(areKnownIdentical(s1, nil))
    print(areKnownIdentical(nil, s1))
    print(areKnownIdentical(s3, s3))
    print(areKnownIdentical(s1, s2))

    let i1 = 5
    let i2 = 9
    let i3: Int? = nil

    print("\nInt:")
    print(areKnownIdentical(i1, i1))
    print(areKnownIdentical(i1, nil))
    print(areKnownIdentical(nil, i1))
    print(areKnownIdentical(i3, i3))
    print(areKnownIdentical(i1, i2))
  }
}

And this gives product engineers the option to either call the free function or the instance method to determine identity equality. Would you have any thoughts about that? Would the freedom to choose the free function make you indifferent to whether or not the protocol method was static or instance?

A primary style would serve this feature better, I think, as it’s a more advanced use case and so more confusion to people reading code where two styles of a seldomly used API are in use. My demo also allows accessing the static func on the type directly, but at that point it’s easy to drop the type name, so it’s less of a separate style.

The main objection I have to lhs.isKnownIdentical(to: rhs) is that identity checking, like equality checking, should not treat the two sides differently. a.isKnownIdentical(to: b) should be the same as b.isKnownIdentical(to: a). And it is. But a?.isKnownIdentical(to: b) is not the same as b?.isKnownIdentical(to: a) and that is easy to miss.

Another reason against the member method is that it adds one more option to the autocomplete menu. This is more a feature that you use when you know you need it, and less one you should stumble over when viewing the autocomplete menu after typing .

3 Likes

This is a fair point… and FWIW the current pitch also does not propose a version of isKnownIdentical that supports optionals. An "alternative considered" is a "first class" override to support Optionals… but it is not part of the "core" pitch because it uses no private or internal symbols. A product engineer that needed or wanted this override could choose to compile that in their own project.

Did I understand your example correctly? In your example a and b are both optional?

1 Like

No, not necessary. The .? after the lhs optional is just easy to misuse since the return value is already an optional.

1 Like

On this note, it's also worth pointing out that the section on Argument Labels in the Swift API Design Guidelines calls out the situation you're talking about, saying

  • Omit all labels when arguments can’t be usefully distinguished, e.g. min(number1, number2), zip(sequence1, sequence2).

While that does seem to focus specifically on the labels, I think there's an unstated implication that an API that doesn't distinguish between two (or more) arguments shouldn't privilege one of them by making it the receiver—and the prior art of functions like min and zip reinforce that.

5 Likes