Any reason to avoid `var id: Self { self }`?

I often find myself needing view content types that are Identifiable (for SwiftUI ForEach) and Equatable (mainly because it's nice when asserting expected content of view models in unit tests). So I cannot use e.g. let id = UUID().

Are there any drawbacks of using var id: Self { self } like this:

  struct Section: Identifiable, Hashable {
    let title: String?
    let rows: [Row]
    var id: Self { self }
  }
  
  enum Row: Identifiable, Hashable {
    case generic(label: String, value: String)
    case infoBox(text: String)
    ...
    var id: Self { self }
  }

Conceptually, it's a bit "weird" that a Section's identification would include the data from all of its rows.

Concretely, if there's any place where the framework is asking for the id and then storing it somewhere (say, a dictionary to cache elements), I'd expect that this could cause performance issues if it ended up creating an entirely new copy of self for that purpose. Maybe it's less severe in the case of Section because the copy of the array would be CoW and not actually deeply performed if the section was never mutated, but that's starting to veer in a direction where I wouldn't want to depend on that always being true (i.e., you make some change to the data structure in the future and it becomes large without the indirection that Array currently provides).

Likewise, since IDs are likely to be hashed, you're paying the cost to hash the entire rows array in the Section case as well.

2 Likes

I’m not sure about reasons that might be beside that, but I avoid conforming to Identifiable if there is no meaningful id can be provided, and then use id: \.self in SwiftUI directly, so it’s obvious on the usage side. Using self as id on declaration side more likely will have performance implications, hidden to the user of the type.

4 Likes

The first two that come to mind:

  • the hash method becomes linear in the number of rows
  • no way to animate changes when an element changes because its identity changes as well
3 Likes

I do this as soon as SwiftUI has a requirement of Identifiable and that I don't feel like writing yet-another-wrapper-type-just-because-SwiftUI-wont-accept-Hashable. Five occurrences in the SwiftUI app I'm working on, currently (mostly enums). Yeah, just go ahead.

As others have noted, don't make your program bad by forcing id equality/hashability to iterate a lot of data. Sure, premature optimization is evil, but bad programs are... bad.

You’ll lose nice animations of changes for starters as SwiftUI will treat a changed item as a new item (and a section with a minute change to a single item as a brand new section). I’d recommend having real id, be it uuid or auto incrementing int or something like that.

3 Likes

Yes, there several reasons, e.g. view lifetime and performance that depends on identifier stability. Imagine a tariff details screen, where Text("price:") is shown several times:
`
Tariff total
price:
99.9

... some marketing text

SMS total
count:
100
price:
5

... some description

Minutes total
price:
55.9
`

So there are three views with the text "price:". How SwiftUI can differ them from each other? How can we programmatically scroll to a concrete "price:" view? This is were correct ID is needed. It is also needed for proper performance and correct animations.
Using self as ID can not achieve the tasks described above. In my own practice, self can be rarely used as ID if we want to get definitely right behaviour, though this approach can very often be seen in internet articles and blogs. ID collisions is a well known problem for any system using Diffable algorithms – I've faced it for RxDatasources, UIKit's UITableViewDiffableDataSource and SwiftUI.
Even if you have a layout where all elements are guaranteed to be unique and exist in a single instance, using self as ID have problems in practice. If you have a view that e.g. show cart products count and count: Int value itself is used as ID, than from SwiftUI point of view every time count value is changed then current counter view end its existence and a new one is created (because view lifetime = ID lifetime), breaking the animations.

I highly recommend to see this WWDC video: Demystify SwiftUI - WWDC21 - Videos - Apple Developer

Feel free to ask clarifying questions.

1 Like

I should have mentioned that the content (sections) will not change during the life time of the view.

In this case you should only make shure all views have unique IDs to avoid ID collisions. If there is a strong guarantee that content for every view is unique (no equal values inside screen view) then it is ok to use content values themselves as ID.
SwiftUI is ok with id collisions, though - I mean it will not crash or assert, but there might be performance problems.
But if you e.g. have several Diffable technologies then it is not suitable. In my project we can have 2 UI implementations for the same Interactor / Presenter pair - one implemented with UIKit and one with SwiftUI. RXDatasources emit runtime warnings when IDs duplication happen, and UITableViewDiffableDatasource is crashed. So we generally use unique IDs as a unified solution, providing internally guidelines, tools and techniques for the team to achieve this.

One more thing to mention: using the whole section itself as ID can turn into performance problem. Section can contain a lot of rows, each row can contain multiple String values.
As IDs are compared, such whole-section-as-ID comparison turns into comparison of all nested String values which can take significant CPU time.

Thanks for the insights!

So what's a better approach for meeting these requirements?

  • Needs to be compatible with SwiftUI's ForEach. Content is static—sections and rows remain constant throughout the view's lifespan. There will be 1 to 4 sections and 3 to 7 rows in each section. There will never be any duplicated sections or rows in a view, i.e. a view will never have two sections (or rows) a and b where a == b.

  • Sections and rows should conform to Equatable for ease of use in tests, like having expectedContent: [Section] = ... and using XCTAssertEqual(observedContent, expectedContent).

For these concrete requirements it is ok to use self as ID, no matter you use SwiftUI / RxDatasource / UIKit's diffable.

There sometimes might be situations when Row enum contains non-hashable associated value. It can be workarounded in a such way:

enum Row: Identifiable, Hashable {
    case generic(label: String, value: String)
    case infoBox(value: EquatableExcluded<NotHashableValue>, text: String)
    ...
    var id: Self { self }
  }

@propertyWrapper
public struct EquatableExcluded<T>: Equatable {
  public var wrappedValue: T
  
  public static func == (lhs: Self, rhs: Self) -> Bool { true }
}

extension EquatableExcluded: Hashable {
  /// Empty Implementation
  public func hash(into hasher: inout Hasher) {}
}

Just to mention, for test purposes elements are not necessary to be Equatable. Values can be compared for equality with the help of reflection. I remember there was a library for doing that, though I can't find the GitHub link.

1 Like

2 options:

  1. Assign them a stable value (e.g. 0, 1, 2, ..., or create a Section.ID enum)

  2. Continue using a UUID (which is only okay because the data won't change), but overwrite the ID of either the actual or expected data when performing your unit tests. You'd probably want to add an explanatory comment, too.

    // We don't care about the ID's value.
    expected.id = actual.id
    XCTAssertEqual(expected, actual)
    

Personally, I'd prefer option 1. UUIDs are larger, slower to hash, slower to check for equality, and slower to generate than a simple integer or small enum value. Even for data that doesn't change, they're suboptimal.

1 Like

Sounds good, although I'm unsure about the concrete implementation of option 1.
Explicitly setting each id for each created value seems error-prone and overly verbose, so I assume you mean something more automatic than that.

How would you implement option 1 here?

struct Section: Hashable, Identifiable {
  let title: String?
  let rows: [Row]
  let id = 0
}

enum Row: Hashable, Identifiable {
  case generic1by1(label: String, value: String)
  case infoBox(String)
  var id: Int { 0 }
}

func example() {
  let sections: [Section] = [
    .init(
      title: nil,
      rows: [
        .generic1by1(label: "First Name", value: "Emily"),
        .generic1by1(label: "Last Name", value: "Smith"),
        .infoBox("Welcome to the app!")
      ]
    ),
    .init(
      title: "Personal Info",
      rows: [
        .generic1by1(label: "Age", value: "67"),
        .generic1by1(label: "Location", value: "Los Angeles")
      ]
    )
  ]
  let copy = sections
  print(sections == copy) // true
}
example()

I don't think it's more error-prone or verbose than anything else. It's quite straightforward to write the numbers 0, 1, 2,... for each section and row :man_shrugging:.

If you really want to automate it, you can use IndexedCollection from swift-algorithms (unfortunately the standard library's EnumeratedSequence doesn't conditionally conform to RandomAccessCollection). If you do that, Section and Row don't need to be Identifiable at all:

struct Section {
  let title: String?
  let rows: [Row]
}

let example: [Section] = [
  ...
]

ForEach(example.indexed(), id: \.index) { (_, section) in
  Text(String(describing: section))

   // Similarly, can do:
   // ForEach(section.rows.indexed(), id: \.index) { (_, row) in ... }
}
1 Like

May be

    ForEach(Array(rows.enumerated()), id: \.offset) { _, _ in
      Text("")
    }
1 Like

I ended up not conforming the content types to Identifiable, but adding this:

extension Collection where Index == Int {
  func indexed() -> [(index: Int, element: Element)] {
    Array(zip(indices, self))
  }
}

(because the project doesn't depend on swift-algorithms.)

And then:

      ...
      ForEach(sections.indexed(), id: \.index) { (_, section) in
        if let title = section.title {
          ... title ...
        }
        view(for: section.rows)
      }
      ...

      ...
      ForEach(rows.indexed(), id: \.index) { (_, row) in
        switch row {
          ...
        }
      }
      ...

Which makes me wonder if there is an alternative to ForEach that takes a collection and does the same thing (without requiring Identifiable or anything, just ForEach(sections) { section in ... }), for use cases like this (where elements will not change). It would probably be trivial to write such a type otherwise. In my experience it is a very common use case, that the elements of a ForEach will not change during the lifetime of a view.

Yes, at least en extension to ForEach can be written. The downside of Array(collection) is additional allocation, which might not necessary be a problem. This is only done in our current discussion because ForEach requires RandomAccessCollection, but it can be easily workarounded by a lightweight wrapper IndexedCollection which conforms to RandomAccessCollection.

It is hilarious that in my experience it was extremely rare case

I was thinking something like:

/// A variant of `ForEach` that iterates over any collection, without requiring
/// the collection's elements to conform to `Identifiable`.
///
/// Due to its reliance on indices for identification, `StaticForEach` is suitable
/// only for content that will not change during the lifetime of the view.
///
/// - Parameters:
///   - data: The collection over which to iterate.
///   - content: A closure that returns the view for each element in the collection.
///
struct StaticForEach<Content: View, Data: Collection>: View where Data.Index == Int {
  private let indicesAndElements: [(index: Int, element: Data.Element)]
  private let content: (Data.Element) -> Content
  
  public init(_ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) {
    self.indicesAndElements = Array(zip(data.indices, data))
    self.content = content
  }
  
  var body: some View {
    ForEach(indicesAndElements, id: \.index) { _, element in
      content(element)
    }
  }
}

which let's me write:

  StaticForEach(sections) { section in
    ...
  }