JetForMe
(Rick M)
1
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
jrose
(Jordan Rose)
2
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
JetForMe
(Rick M)
3
But in this case, the value isn't modified, so I feel like it should not copy the value back.
Jumhyn
(Frederick Kellison-Linn)
4
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
JetForMe
(Rick M)
5
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
jrose
(Jordan Rose)
6
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
hgraves
(hgraves)
7
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.
trochoid
(Trochoid)
8
Could PropertyWrapper or KeyPath be a solution?
tera
9
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
tera
10
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
Jumhyn
(Frederick Kellison-Linn)
11
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
Joe_Groff
(Joe Groff)
12
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