정확한 내용은 원문 참고 바랍니다.
Overview
Consume noncopyable types in Swift.
ownership, noncopyable type에 대한 소개 및 noncopyable을 제네릭, extension에서 사용하는 법
Copying
Automatic copying
아래 코드는 value 타입으로, player1을 player2로 복사(copying)하는 것이다.
그래서 player2의 아이콘을 변경하여도 player1에 영향을 끼치지 않는다.
struct Player {
var icon: String
}
func test() {
let player1 = Player(icon: "🐸")
var player2 = player1
player2.icon = "🚚"
assert(player1.icon == "🐸") // true
}
Player가 참조 타입이라면?
클래스를 생성할 때, 객체는 데이터가 분리되어 저장된다.
player1의 내용은 객체에 의해 참조가 자동으로 관리된다.
그래서, player2는 객체를 복사하는 게 아니라 player1의 참조를 복사한다는 걸 의미한다.
때때로 이걸 매우 빠른 얕은 복사(shallow copy which is very quick)라고 부른다.
참조를 복사하기 때문에 player2의 아이콘을 변경하면 두 player 모두 아이콘이 변경된다.
class PlayerClass {
var icon: String
init(_ icon: String) { self.icon = icon }
}
func test() {
let player1 = PlayerClass("🐸")
let player2 = player1
player2.icon = "🚚"
assert(player1.icon == "🐸") // false
}
Deep Copying
위 두 사례 유일한 차이점은 값 타입이냐 혹은 참조 타입이냐 다.
혹은, 심층 복사(Deep copy)를 동작하기 위해 생성자를 정의하면 참조 타입을 값 타입처럼 동작하게 할 수도 있다.
player1, player2는 같은 객체를 참조하고 있지만, 생성자를 통한 deep copy를 만들었기 때문에 PlayerClass(from: player2)를 통해 따로 할당되며 각 변수는 서로에게 영향을 주지 않는다.
이것이 cow(copy on write)의 기본이다.
class PlayerClass {
var data: Icon
init(_ icon: String) { self.data = Icon(icon) }
init(from other: PlayerClass) {
self.data = Icon(from: other.data)
}
}
func test() {
let player1 = PlayerClass("🐸")
var player2 = player1
player2 = PlayerClass(from: player2)
player2.data.icon = "🚚"
assert(player1.data.icon == "🐸")
}
struct Icon {
var icon: String
init(_ icon: String) { self.icon = icon }
init(from other: Icon) { self.icon = other.icon }
}
Copyable
타입을 정의할 때 이미 심층 복사가 가능하도록 제어권을 가지고 있었고, 제어하지 못하던 것은 Swift가 그 타입을 자동으로 복사하도록 관리하는 권한이다.
Copyable은 새로운 프로토콜로 타입이 자동적으로 복사될 수 있는지에 대한 능력을 가진다.
또한, Sendable처럼 member를 보유하지 않는다.
Swift의 모든 것(타입, 제네릭, 프로토콜, 연관 타입, boxed 프로토콜, …)은 Copyable 하다.
보이지 않아도 할당되어 있기 때문.
하지만 복사가 에러를 일으키는 경우도 있다.
백엔드의 은행 송금을 위한 타입을 위한 작업을 한다고 가정해 보자.
현실에서 송금은 pending, calcelled, complete이다.
아래 송금을 완료하는 메소드가 있다.
class BankTransfer {
func run() {
// .. do it ..
}
}
func schedule(_ transfer: BankTransfer,
_ delay: Duration) async throws {
if delay < .seconds(1) {
transfer.run()
}
try await Task.sleep(for: delay)
transfer.run()
}
하지만, 만약 실수로 송금이 두 번 수행되는 경우 유저는 기뻐하지 않을 것이다.
더블 체크하도록 고쳐보자.
class BankTransfer {
var complete = false
func run() {
assert(!complete)
// .. do it ..
complete = true
}
deinit {
if !complete { cancel() }
}
func cancel() { /* ... */ }
}
func schedule(_ transfer: BankTransfer,
_ delay: Duration) async throws {
if delay < .seconds(1) {
transfer.run()
}
try await Task.sleep(for: delay)
transfer.run()
}
func startPayment() async {
let payment = BankTransfer()
log.append(payment)
try? await schedule(payment, .seconds(3))
}
assertion을 통해서 두 번 수행되는 걸 캐치할 수 있다.
하지만, 버그에 맞는 테스트를 작성하지 않는 한 assertion은 버그를 찾아내지 못할 것이다.
예를 들어 sleep 작업이 취소된다면, error를 throw 하지 못하고, 송금을 취소하지 못한다.
deinit을 사용해 송금을 취소시킬 순 있지만 실제론 별로 쓸모없다.
class BankTransfer {
// ...
deinit {
if !complete { cancel() }
}
// ...
}
func startPayment() async {
let payment = BankTransfer()
log.append(payment)
try? await schedule(payment, .seconds(3))
}
왜냐, startPayment 함수의 클로저를 보면 송금의 복사를 유지하고, 이 부분이 문제점이다.
BankTransfer의 deinit은 모든 복사가 사라지기 전 까진 수행되지 않기 때문.
Noncopyable types
대부분의 경우 타입이 copyable 한 게 좋지만, 방금 전 상황처럼 noncopyable이 더 나은 경우도 있다.
tilde(~)를 붙이면 no Copyable 타입으로 만들 수 있다.(~Copyable)
이렇게 되면 Swift는 복사를 하는 대신 소비(consume)하게 된다.
struct FloppyDisk: ~Copyable {}
func copyFloppy() {
let system = FloppyDisk()
let backup = consume system
load(system)
// ...
}
func load(_ disk: borrowing FloppyDisk) {}
consume은 값을 변수로 받아 변수의 초기화되지 않은 상태로 만든다.
system은 consume 되었기 때문에 load(system) 은 에러 발생
Ownership
consuming: 인자를 consume
struct FloppyDisk: ~Copyable { }
func newDisk() -> FloppyDisk {
let result = FloppyDisk()
format(result)
return result
}
func format(_ disk: consuming FloppyDisk) {
// ...
}
borrowing: 인자의 권한을 잠시 빌린다. borrowing은 인자의 읽기 권한을 주는 것으로 let 과 같다.
struct FloppyDisk: ~Copyable { }
func newDisk() -> FloppyDisk {
let result = FloppyDisk()
format(result)
return result
}
func format(_ disk: borrowing FloppyDisk) {
var tempDisk = disk // dist is borrowed and cannot be consumed
// ...
}
consuming과 borrowing의 차이는 명시적으로 borrowing 한 인자를 consume 하거나 변경(mutate)할 수 없다는 것.
그저 복사 밖에 안된다.
하지만, 포맷 기능은 disk를 변경하는 것이기 때문에 borrowing으로는 동작하지 않는다.
마지막 onwership은 친숙한 inout
struct FloppyDisk: ~Copyable { }
func newDisk() -> FloppyDisk {
var result = FloppyDisk()
format(&result)
return result
}
func format(_ disk: inout FloppyDisk) {
var tempDisk = disk // Missing reinitialization of inout parameter 'disk' after consume
// ...
disk = tempDisk
}
inout은 일시적으로 변수의 쓰기 권한을 주지만, consume을 사용하기 위해선 함수가 끝나기 전에 초기화를 다시 해줘야 한다.
Consumable resources
다시 BankTrasfer 예제로 돌아와서, 우선 noncopyable 한 구조체로 만들고, run 함수를 consuming으로 만든다.
이렇게 되면 Swift는 한 송금에 두 번의 run 함수를 호출하지 못한다는 걸 보장한다.
그리고 discard self를 추가해서 run이 정상적으로 동작한다면 deinit을 호출하지 않고 구조체를 파괴하게 할 수 있다.
struct BankTransfer: ~Copyable {
consuming func run() {
// .. do it ..
discard self
}
deinit {
cancel()
}
consuming func cancel() {
// .. do the cancellation ..
discard self
}
}
func schedule(_ transfer: consuming BankTransfer,
_ delay: Duration) async throws {
if delay < .seconds(1) {
transfer.run()
return
}
try await Task.sleep(for: delay)
transfer.run()
}
'WWDC' 카테고리의 다른 글
WWDC24: Demystify explicitly built modules (1) | 2024.09.06 |
---|---|
WWDC24: Run, Break, and Inspect Explore effective debugging in LLDB (0) | 2024.07.22 |
WWDC24: SwiftUI 컨테이너 쉽게 이해하기 (0) | 2024.07.02 |
Teck Talks: 더 적은 데이터로 더 많은 작업하기 (0) | 2024.06.29 |
WWDC24: Swift on Server 생태계 (0) | 2024.06.28 |