Surprising behavior of RealityKit Entity.childCollection

There is a lengthy and interesting discussion on the Vision Pro #developer Discord channel that boils down to surprising behavior removing elements of a collection. (Well, perhaps not surprising to folks here.)

children.forEach { $0.removeFromParent() }

fails to remove all children of RealityKit Entity.ChildCollection, as it uses an IndexingIterator with Int as an index type, if I understood the discussion correctly.

While the responsibility ultimately lies with the RealityKit API choice which is off-topic here, the underlying collection and iterator types causing the confusion are something the consumer of the API doesn't see and can usually ignore completely; similar APIs such as UIKit, SpriteKit, and SceneKit vend similar node hierarchies where the above code works as expected using different underlying types.

I post there here wondering if folks have anything to add to the Discord discussion on the language level. What could make such confusions either less likely in the first place, or easier to diagnose when they occur? What is the simplest idiomatic work-around to the problem?

It’s generally not kosher to mutate collection while iterating it, regardless of the API.

1 Like

It’s common and expected that Apple’s various view .removeFrom APIs handle the case correctly, as this is exactly how it’s supposed to be used.

Simply put, if the API was implemented correctly, the collection and iterator types shouldn't matter.

1 Like

It only works because the method internally makes a copy of the array before returning it as part of a caching optimization. Prior to macOS Sonoma, NSView.subviews didn’t make a copy, so this exact pattern would cause an AppKit app to crash. (In fact, some applications depended on AppKit returning the mutable array directly and had to be worked around!)

While it is certainly a safer API design to copy the collection before returning it, you should generally copy any collection if you’re going to iterate over it in a way that can potentially mutate it. If the framework has already copied it for you, the extra “copy” is cheap.

Though I must admit, after further thought this possibility is even more obscure in a language with value-type arrays. At least in ObjC you can reason about the array you get back being an instance of a mutable subclass.

1 Like

Will this work?

parent.children.removeAll()

Probably this will, but I don't like it:

children.reversed().forEach { $0.removeFromParent() }

Other than that, perhaps remove the first (or last) element until there are no children left, pseudocode:

while !parent.children.isEmpty {
    parent.children[0].removeFromParent()
    // or if there is a more direct:
    // parent.remove(at: 0)
    // then use that
}

BTW, this doesn't work either:

var children = [1:1, 2:2, 3:3, 4:4, 5:5, 6:6]
children.indices.forEach { i in
    children.remove(at: i)
}

as indices could be invalidated after mutations.

This is exactly the kind of code that AppKit had to work around. Please don’t do this. You’re just making the opposite assumption.

Please clarify. The idea was to combat the potential (or real) issues that could arise during collection mutation during enumeration by removing one element and then "starting over" the enumeration (of even if the relevant iterator was invalidated during mutation it won't matter). It might not be efficient but it is at least safe. A classical example for a dictionary:

var children = [1:1, 2:2, 3:3, 4:4, 5:5, 6:6]

// let's pretend we don't have neither this
// children.removeAll()
// nor this
// children = [:]
// nor this
// for key in children.keys {
//    children.removeValue(forKey: key)
//}
// nor this:
// for kv in children {
//    children.removeValue(forKey: kv.key)
// }
// bad way to empty dictionary (will crash):
// children.indices.forEach { i in
//    children.remove(at: i)
// }

// not ideal but will do the job:
while !children.isEmpty {
    children.remove(at: children.indices.first!)
}

Sorry, your version should be fine. But a subtle refactor could make it loop infinitely if children ever starts returning a copy:

let children = parent.children
while !children.isEmpty {
    children[0].removeFromParent()
}