Swift에서 프로토콜의 mutating 요구사항과 프로토콜 확장 기본 구현이 클래스(참조 타입)와 만날 때 예상치 못한 동작을 낳는 문제를 살펴본다.
URL: https://belkadan.com/blog/2021/08/Swift-Regret-Mutating-Protocol-Methods/?tag=swift-regrets
Title: Swift Regret: mutating Protocol Methods vs. Classes
Swift Regrets 시리즈의 일부.
Swift에서 mutating은 본질적으로 inout의 설탕(syntactic sugar)이며, Swift에서 inout은 형식적으로 copy-in/copy-out을 의미하지만 종종 제자리(in-place) 변이로 최적화됩니다. 값 타입에는 이게 매우 타당하지만, 클래스에는 그다지 그렇지 않습니다. 클래스는 임의로 복사 가능한 것이 아니니까요. 여기서 여러분이 이리저리 전달하고 “복사”하는 것은 참조(reference)입니다. 그래서 클래스 메서드는 mutating으로 만들 수 없습니다.
프로토콜이 등장합니다. 프로토콜은 mutating 요구사항(requirement)을 가질 수 있습니다. 예를 들어:
protocol Sortable {
mutating func sortInPlace()
}
func isSorted<List: Sortable & Equatable>(_ list: List) -> Bool {
var copy = list
copy.sortInPlace()
return list == copy
}
그다지 좋은 구현은 아니지만, 요지는 알겠죠. 이 코드는 (copy-on-write인) Array 같은 타입에서는 동작하지만, NSMutableArray 같은 가변(mutable) 클래스에서는 무너집니다. var copy = list는 전혀 복사가 아니며, 원본 NSMutableArray가 변이되어 버립니다!
프로토콜 확장(protocol extensions)(제가 기억하기로는 Swift 2에 추가됨)으로 상황은 더 악화되었습니다:
protocol Default {
init()
}
extension Default {
mutating func reset() {
self = Self()
}
}
다시 말해, Array에서는 잘 동작합니다. 기존 배열을 copy-in(저렴함: 저장소를 공유하고 copy-on-write이므로)하고, 새 배열을 copy-out합니다. 하지만 NSMutableArray에서는 _참조_를 copy-in하고, 새 _참조_를 copy-out하게 됩니다. sort()와 달리, 우리는 공유된 NSMutableArray를 수정하지 않았기 때문에 다른 참조들은 reset되지 않습니다. 이는 클래스에서 호출하는 다른 어떤 메서드와도 다른 동작입니다. (SR-142 참고.)
그러니까, 음. 클래스가 프로토콜을 채택(conform)할 때 mutating 요구사항은 예상하지 못한 공유 변이(shared mutation)를 낳을 수 있고, mutating 확장 메서드는 예상하지 못한 비공유 변이(non-shared mutation)를 낳을 수 있습니다. 그리고 확장 메서드는 기본 구현(default implementation)이니…
당시 Dave Abrahams는 클래스가 mutating 요구사항이 있는 프로토콜에 채택하는 것을 허용하지 말아야 한다는 아이디어를 갖고 있었습니다. 최소한 그는 이 문제를 제기했고 우리가 더 고민해 보길 바랐습니다. 그리고 Dave는 기껏해야 클래스의 존재를 용인하는 편이죠. ;-) 하지만 그건 확장 메서드 부분을 해결하지 못하고, 음, 사실 이건 클래스 자체라기보다 참조 _시맨틱_에 관한 문제입니다. (isSorted를 UnsafeMutableBufferPointer와 함께 쓰는 경우를 생각해 보세요.)
그래서 우리는 답을 내지 못했고, 지금도 저는 답이 없지만, 여전히 이게 거슬립니다.
P.S. 이 스레드를 트윗한 뒤 여러 사람이 “AnyValue”에 이게 잘 맞는 용도라고 지적해 주었습니다. AnyObject의 반대 개념으로, 구조체와 열거형에만 적용되는 제약이죠. 저는 역사적으로 AnyValue를 제네릭 제약으로 두는 것에 반대해 왔는데, 그 이유는 참조 시맨틱을 가진 구조체를 고려하지 못하기 때문입니다. 하지만 단순 대입으로 self를 복사하는 함수, 또는 self를 재대입해야 하는 mutating 확장 메서드를 표시하는 마커(marker)로는 정말 좋은 해법이 될 수 있습니다. 그래서 저는 마침내 그 아이디어 쪽으로 마음이 돌아섰습니다.
이 글은 2021년 8월 11일에 게시되었고 Technical 카테고리에 속합니다. 태그: Swift, Swift regrets