Why does this assignment happen even when the values are equal?

Writing some Core Data code, I'm running into the situation where assigning to a Core Data property, even if it's the same value, marks the object as dirty. So I'm testing each assignment before assigning it:

if reminder.fireDate != newFireDate {
   reminder.fireDate = newFireDate
}

This was a bit tedious after a few, so I thought I'd be clever and create an "assign if unequal" operator for myself:

infix operator =!? : AssignmentPrecedence
	
func =!?<T>(lhs: inout T, rhs: T) where T : Equatable {
	if lhs != rhs {
		lhs = rhs
	}
}

And call it like this:

reminder.fireDate =!? newFireDate

Unfortunately, the Core Data object still ends up getting its property assigned, even though the values are equal and internal assignment doesn't occur (as evidenced by a call to the entity’s willChangeValue(forKey:)). I don't really understand what’s happening. Can anyone shed light on it?

1 Like

inout is named very deliberately: formally, it is a copy-in/copy-out* operation regardless of whether it can be optimized to a direct access. This has pros and cons, of course, but that’s how it is.

* eventually “move-in/move-out”

3 Likes

But in this case, the value isn't modified, so I feel like it should not copy the value back.

The semantics of inout are what they are and we couldn’t very well make a change to the behavior like that at this point. Passing an inout argument constitutes a mutation externally regardless of whether the internal parameter actually gets mutated itself. This has the benefit of making the triggering of property observers highly locally predictable rather than being at the mercy of the callee’s implementation decisions.

1 Like

I'm not sure I agree with the assertion that it’s highly predictable, certainly not without a-priori knowledge of the rather unintuitive behavior of inout. And despite @jrose's implication that the deliberate naming implies it will always be assigned something, one cannot guess that simply by reading it.

I also don't think that triggering a mutation when there is no mutation is a benefit. Can anyone provide a concrete example of why this is actually a good thing?

The more I think about it, the more this seems absolutely bonkers to me. Is there an alternative to inout that doesn’t do this???

2 Likes

The semantics of inout are, formally, always

func modify(_ value: inout Foo) {
  if value.someCondition { value = Foo() }
}

modify(&foos[0])
// becomes
var localValue = foo[0]
modify(&localValue)
foo[0] = localValue

This is a bit handwavy because of the existence of _modify accessors (that will hopefully one day be stabilized), but even in that case there's nothing tracking whether a modification actually took place.

What you'd like (which is reasonable!) is something like

modify(&foos[0])
// becomes
var localValue = foos[0]
var wasModified = false
modify(&localValue, &wasModified)
if wasModified { foos[0] = localValue }

But that's not how it works today, at least partly because that's more generated code

Is there an alternative to inout that doesn’t do this???

There is! It's SwiftUI's Binding (or similar types), though it's longer at the call site:

func modify(@Binding _ value: Foo) {
    if value.someCondition { value = Foo() }
}

modify(Binding { foo[0] } set: { foo[0] = $0 })

Of course, this naive version has trade-offs too: it doesn't have exclusive access, and if you do multiple reads or writes it'll perform the underlying access multiple times. You can use something other than Binding to get around some of this, but exclusivity is still attached to true inout at the moment.

3 Likes

This is just a guess and could be totally wrong, but, could it possibly make some data more resilient to something like a hash collision? My thinking being, if something might have been mutated, presumably if it is a part of a larger object, the object itself might need to recompute its unique identifier, maybe? Perhaps the assumption that any pass by reference is a mutation helps keep key value observers and storage mechanisms able to understand when it’s time to let go of an object / etc.

Could PropertyWrapper or KeyPath be a solution?

We had this thread 8 months ago, and here's a suggested solution.

Generalising:

protocol AssignIfDifferent {}
protocol AssignIfDifferentRef: AnyObject {}

extension AssignIfDifferent {
    mutating func assignIfDifferent<T: Equatable>(_ path: WritableKeyPath<Self, T>, _ newValue: T) {
        if self[keyPath: path] != newValue {
            self[keyPath: path] = newValue
        }
    }
}

extension AssignIfDifferentRef {
    func assignIfDifferent<T: Equatable>(_ path: ReferenceWritableKeyPath<Self, T>, _ newValue: T) {
        if self[keyPath: path] != newValue {
            self[keyPath: path] = newValue
        }
    }
}

struct Reminder: AssignIfDifferent {
    var fireDate: Date = Date.distantPast {
        didSet {
            print("CHANGE!")
        }
    }
}

class ReminderRef: AssignIfDifferentRef {
    var fireDate: Date = Date.distantPast {
        didSet {
            print("CHANGE!")
        }
    }
}

var reminder = Reminder()
let sameDate = reminder.fireDate
reminder.assignIfDifferent(\.fireDate, sameDate) // no change
reminder.assignIfDifferent(\.fireDate, sameDate + 1) // change

let reminderRef = ReminderRef()
let oldDate = reminderRef.fireDate
reminderRef.assignIfDifferent(\.fireDate, oldDate) // no change
reminderRef.assignIfDifferent(\.fireDate, oldDate + 1) // change

With subscripts:

extension AssignIfDifferent {
    subscript<T: Equatable>(path: KeyPath<Self, T>) -> T {
        self[keyPath: path]
    }
    subscript<T: Equatable>(path: WritableKeyPath<Self, T>) -> T {
        get { self[keyPath: path] }
        set {
            if self[keyPath: path] != newValue {
                self[keyPath: path] = newValue
            }
        }
    }
}

extension AssignIfDifferentRef {
    subscript<T: Equatable>(path: KeyPath<Self, T>) -> T {
        self[keyPath: path]
    }
    subscript<T: Equatable>(path: ReferenceWritableKeyPath<Self, T>) -> T {
        get { self[keyPath: path] }
        set {
            if self[keyPath: path] != newValue {
                self[keyPath: path] = newValue
            }
        }
    }
}

The use site becomes:

reminder[\.fireDate] = sameDate // no change
3 Likes

Alternatively with the AccessTracker idea:

@dynamicMemberLookup
struct AccessTracker<Value> {
    private var value: Value

    init(_ value: Value) {
        self.value = value
    }
    subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
        value[keyPath: keyPath]
    }
    subscript<T: Equatable>(dynamicMember keyPath: WritableKeyPath<Value, T>) -> T {
        get {
            value[keyPath: keyPath]
        }
        set {
            if value[keyPath: keyPath] != newValue {
                value[keyPath: keyPath] = newValue
            }
        }
    }
}

The usage could be:

var reminder = AccessTracker(Reminder())
let old = reminder.fireDate
reminder.fireDate = old // no change
reminder.fireDate = old + 1 // change

var reminderRef = AccessTracker(ReminderRef())
reminderRef.fireDate = old // no change
reminderRef.fireDate = old + 1 // change
2 Likes

Perhaps ‘predictable’ isn’t the best word, since as you note it is still a non-obvious behavior that must be learned, but IMO the important aspect of the behavior today is that it makes answering the question, “will a mutation be performed, semantically?” a matter of looking at the interface of the callee rather than the implementation.

It also means that there’s no need to worry about the difference between, say, setting to the same value (e.g., x = x) and not setting at all, so implementors can enact optimizations like eliminating such unnecessary mutations without concern about breaking client assumptions.

The general tool for “I want to give the callee a handle to the same mutable state” is reference types (which wouldn’t need inout at all here) as with the closures in Jordan’s example, but value types are deliberately biased toward preserving local reasoning re: mutability.

4 Likes

In addition to the reasons @Jumhyn noted, it's also the case that not every type has a definition of equality, and some weird types have notions of equality that don't correspond to being identical. If you have:

func foo(_: inout Float) {
  x = -0
}

var y: Float = 0
foo(&y)

then y ought to be -0 after calling foo even though 0 and -0 are "equal" according to Float's definition. Similarly with classes:

class Foo { var value: Int; init(value: Int) { self.value = value } }

func foo(_ x: inout Foo) {
  x = Foo(value: 123)
}

var y = Foo(value: 123)
var z = y
foo(&y)
assert(y !== z)

even though the two instances of Foo have identical contents, they're separate objects, and we'd expect the assertion to hold after foo runs that y was reassigned to the new object instance. The behavior of inout provides uniform behavior regardless of the implementation details of what type you're working with.

Using attached macros, it might be interesting to write a property-wrapper-like macro for properties to attach a didChange observer to a property, which would behave similarly to didSet except that it would only run the observer in the case where newValue != oldValue.

9 Likes