Skip to content

[RFC] custom did change operators as alternatives to value equality #27

@vanvoorden

Description

@vanvoorden

When we add an Equatable macro we generate an == operator that performs value-equality checks over the instance properties that have not implicitly or explicitly been ignored:

struct ContentView {
  let x: Int
  let y: Int
}

extension ContentView: Equatable {
  nonisolated static func ==(_ lhs: ContentView, _ rhs: ContentView) -> Bool {
    guard lhs.x == rhs.x else {
      return false
    }
    guard lhs.y == rhs.y else {
      return false
    }
    return true
  }
}

This is semantically correct by the conventional definition of value-equality. But what if we don't want the "conventional" definition?

Let's consider another example:

struct ContentView {
  let smallClass: SmallClass
  let largeClass: LargeClass
  let smallStruct: SmallStruct
  let largeStruct: LargeStruct
}

extension ContentView: Equatable {
  nonisolated static func ==(_ lhs: ContentView, _ rhs: ContentView) -> Bool {
    guard lhs.smallClass == rhs.smallClass else {
      return false
    }
    guard lhs.largeClass == rhs.largeClass else {
      return false
    }
    guard lhs.smallStruct == rhs.smallStruct else {
      return false
    }
    guard lhs.largeStruct == rhs.largeStruct else {
      return false
    }
    return true
  }
}

There's nothing "wrong" with this implementation… but a product engineer might have stronger opinions about how we determine two ContentView instances are "not equal".

Let's start with our class references. Suppose our product engineer did not want to perform a check for value equality but only wanted to perform a check over reference equality:

extension ContentView: Equatable {
  nonisolated static func ==(_ lhs: ContentView, _ rhs: ContentView) -> Bool {
    guard lhs.smallClass === rhs.smallClass else {
      return false
    }
    guard lhs.largeClass === rhs.largeClass else {
      return false
    }
    guard lhs.smallStruct == rhs.smallStruct else {
      return false
    }
    guard lhs.largeStruct == rhs.largeStruct else {
      return false
    }
    return true
  }
}

We don't currently have any way to support this from our macro. If the check for value equality checks many more bytes than the width of one pointer we could be spending more time than necessary over these checks. There might some edge cases here to defend against: if a pointer is unowned and not strongly referenced it could be "unsafe" to attempt to compare this pointer across two moments in time. But if the product engineer controls that pointer and knows this is ok that could be their decision to make.

A bigger question here is what we really intend to communicate with this == operator on a view component. Is our main goal "proving" that these two instances are equal by value… or is our goal just to give a hint to the SwiftUI infra that these two instances "might have changed" and should compute a new body property?

If our product engineer thinks a little more flexibly about what this == actually means we might choose to improve performance by actually skipping value equality over our instance variables. Suppose LargeStruct is a "big" type: many more bytes of storage than the width of one pointer. Our product engineer might use Swift-CowBox to improve performance when copying: it's a copy on write data structure that preserves value semantics and optimizes performance with a private reference.

The CowBox macro applied an isIdentical function to copy on write data structures for determining in constant time if two instances are indistinguishable. For copy-on-write data structures that also adopt value equality… returning true for isIdentical implies that == must return true.

With CowBox and isIdentical our product engineer might want to express something like this:

extension ContentView: Equatable {
  nonisolated static func ==(_ lhs: ContentView, _ rhs: ContentView) -> Bool {
    guard lhs.smallClass === rhs.smallClass else {
      return false
    }
    guard lhs.largeClass === rhs.largeClass else {
      return false
    }
    guard lhs.smallStruct == rhs.smallStruct else {
      return false
    }
    guard lhs.largeStruct.isIdentical(to: rhs.largeStruct) else {
      return false
    }
    return true
  }
}

Now the == operator on our ContentView no longer expresses "conventional" value equality: we overload the semantic meaning of ==. Two ContentView instances that return true for == are two instances that must be equal by value. Two ContentView instances that return false for == might be equal by value. The performance optimization is that we could return true quickly: our "shallow" equality could return must faster than "deep" equality.

If we assume that the SwiftUI infra uses a true value from ContentView to skip computing a new body this approach might save us performance. We run the risk of computing a new body more often than necessary… but if the work to compute that body is comparable to the amount of work performed in conventional value equality then this could be the right tradeoff.

The trouble here is there is no easy way currently for product engineers to provide these "overrides" to our == operator.

I propose a new didChange operator:

extension ContentView {
  nonisolated static func didChange<T: Equatable>(_ lhs: T, _ rhs: T) -> Bool {
    lhs != rhs
  }
}

We could then refactor our == operator to:

extension ContentView: Equatable {
  nonisolated static func ==(_ lhs: ContentView, _ rhs: ContentView) -> Bool {
    guard Self.didChange(lhs.smallClass, rhs.smallClass) == false else {
      return false
    }
    guard Self.didChange(lhs.largeClass, rhs.largeClass) == false else {
      return false
    }
    guard Self.didChange(lhs.smallStruct, rhs.smallStruct) == false else {
      return false
    }
    guard Self.didChange(lhs.largeStruct, rhs.largeStruct) == false else {
      return false
    }
    return true
  }
}

This now gives us a natural place for product engineers to override the behavior of ==. Here is one example:

extension ContentView {
  nonisolated static func didChange<T: Equatable & AnyObject>(_ lhs: T, _ rhs: T) -> Bool {
    lhs !== rhs
  }
}

Without changing the implementation of our == operator our product engineer has now customized the behavior: our compiler will choose identity equality over value equality when our compiler knows an instance variable is a class reference.

We can also use this to adopt our optimization from CowBox:

extension ContentView {
  nonisolated static func didChange<T: Equatable & CowBox>(_ lhs: T, _ rhs: T) -> Bool {
    lhs.isIdentical(to: rhs) != true
  }
}

Our custom copy on write data structures will no longer perform deep value equality: we just return true from identity equality.

Our macro is not directly making its own strong opinions about whether or not this optimization is "the right" thing to do. That decision remains with the product engineers. Since we are "overloading" the original semantic meaning of what it means to return false for == and we are also implicitly depending on undocumented behavior of the internal SwiftUI infra this pattern should be considered an "advanced" performance optimization. Product engineers should not choose this by default… but if a product engineer knows and understands the risks and tradeoffs we can make this available to them.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions