swift에서 copy는 타입이 값타입이냐 참조타입이냐에 따라 다르게 동작한다.
값 타입의 경우 copy는 타입의 모든 저장 프로퍼티를 복사한 새로운 인스턴스를 생성한다. 이것을 Deep copy라고도 한다.
즉 위에서 player2는 player1과 독립적인 새로운 인스턴스이므로, player2의 icon을 변경한다고 해서 player1의 icon에도 영향을 끼치진않는다.
하지만 참조 타입의 경우 copy는 모든 저장 프로퍼티를 복사한 새로운 인스턴스를 생성하는게 아니라, Heap 메모리에 저장된 기존 인스턴스를 똑같이 참조하는 새로운 pointer가 생기는것이다.
이것을 shallow copy라고 한다
즉 여기선 player2의 icon을 변경하면 player1의 icon도 똑같이 변한다. 왜냐면 두 pointer는 똑같은 인스턴스를 참조하고있기 때문이다. 그리고 여기서 똑같은 인스턴스를 player1과 player2가 참조하고 있으므로, reference count는 2가된다.
보통 우리는 동시에 여러 곳에서 상태가 공유되길 원할 때 참조 타입을 사용한다.
또한 shallow copy라는 특성때문에 저장 프로퍼티가 많은 타입의 경우 참조 타입을 사용하는게 성능 상으로 좋을 수도 있다. 왜냐하면 값 타입의 경우 모든 저장 프로퍼티를 복사하기 때문에, 저장 프로퍼티가 많으면 많을수록 copy에 많은 시간이 소요될 수 있기 때문이다.
그라나 참조타입을 사용하면서도 deep copy를 하고싶을 수 있는데, 이럴 땐 아래와 같이 새로운 생성자를 선언할 수 있다.
final class PlayerClass {
var icon: String
init(from otherPlayer: PlayerClass) {
self.icon = otherPlayer.icon
}
}
reference type을 deep copy하는 것이 사실 copy on write(COW)의 본질이다.
swift에서 기본적으로 모든 타입은 복사가 가능하다. swift에선 이것을 새로운 Copyable 프로토콜 타입으로 설명한다. Sendable과 마찬가지로, Copyable 프로토콜은 아무런 member requirement가 없다.
swift에선 타입 뿐 아니라 모든 것이 암시적으로 Copyable 프로토콜을 채택한다.
예를 들면, 아래 예시들이 모두 암시적으로 Copyable 프로토콜을 따른다.
그러나 copyable한 특성을 사용할 땐 실수를 만들기 쉽다.
예를 들어 은행의 transfer를 모델링한다고 생각해보자. real world에선 transfer는 3가지의 상태를 가질 수 있다.
그리고 transfer를 스케줄링하는 schedule이라는 function을 선언하자.
final class BankTransfer {
func run() {
// ...
}
}
func schedule(
_ transfer: BankTransfer,
_ delay: Duration
) async throws {
if delay < .seconds(1) {
transfer.run()
}
try await Task.sleep(for: delay)
transfer.run()
}
schedule 함수는 만약 delay가 1초 미만이라면 transfer를 바로 실행하고, 아니면 지정된 delay만큼 기다렸다가 transfer를 실행하는 함수다.
이 코드에는 중대한 버그가 하나있는데, transfer가 두 번 이상 실행되면 안되지만 첫 번째 transfer.run() 후에 return을 하지않기 때문에 delay가 1초 미만인 경우 transfer가 두 번 이상 실행될 수 있다.
BankTransfer에서 run 메소드가 두 번 이상 실행되는 것을 방지하기 위해, Transfer의 상태를 나타내는 새로운 저장 프로퍼티를 선언하고, run 함수에서 그것을 체크하도록 변경해보자.
final class BankTransfer {
private var completed = false
func run() {
guard !completed else {
return
}
// ...
completed = true
}
}
이제 Transfer가 두 번 이상 실행되진 않겠지만.. 여기선 schedule 함수엔 여전히 버그가 하나 있는데 그건 바로 Task.sleep 함수 때문이다. 만약 schedule 함수를 실행하는 Task가 취소되면 해당 함수는 Error를 던지고, 만약에 callee에서 해당 error를 적절히 처리하지 않으면 Transfer는 영원히 Pending 상태로 남게된다.
let transferTask = Task {
let bankTransfer = BankTransfer()
do {
try await schedule(bankTransfer, .seconds(2))
} catch {
// Should handle error throwed by schedule function
}
}
transferTask.cancel()
이걸 방지하기 위해서, BankTransfer가 deinit 메소드에서 Transfer를 취소해줄 수 있다.
final class BankTransfer {
private var completed = false
func run() {
guard !completed else {
return
}
// ...
completed = true
}
func cancel() {
//...
}
deinit { cancel() }
}
언뜻보면 문제가 모두 해결된 것 같다. 하지만 특정 코드가 bankTransfer의 인스턴스를 계속 참조하고 있는다면.. deinit은 불리지않고 BankTransfer는 적절히 완료되지않은 상태로 프로그램을 계속 떠돌게 될 것이다.
let transferTask = Task {
let bankTransfer = BankTransfer()
do {
log.append(bankTransfer)
try await schedule(bankTransfer, .seconds(2))
} catch {
// Should handle error throwed by schedule function
}
}
위 코드에선 로깅을 위해 BankTransfer의 인스턴스를 복사(retain)했다.
위 예시의 근본적인 문제는 BankTransfer이라는 타입을 무분별하게 복사할 수 있다는 점이다.
때때로 특정 타입은 Copyable 특성을 없애는게 훨씬 도움이 될 수도 있다. 그래서 swift에선 Noncopyable이라는 새로운 특성을 소개한다.
Noncopyable을 설명하기 위해, FloppyDisk라는 타입을 새로 생성해보겠다.
struct FloppyDisk: ~Copyable {
}
swift에서 특정 타입을 Noncopyable로 만들기 위해선 ~Copyable 키워드를 사용할 수 있다.
이제 FloppyDisk는 Noncopyable 타입이기 때문에, 아래와 같이 system이 가지고있는 인스턴스를 새로운 backup에 할당하는 코드는 copy 대신 consume(값을 복사하는 대신 소유권만 이전함)을 수행하게된다.
맨 초반에 swift에서 값 타입의 copy는 모든 저장 프로퍼티를 복사한 새로운 인스턴스를 생성하는 것이라고 했는데, consume은 copy 하는게 아니라 원래있던 값의 소유권만 이전한 것이다.
여기서 consume이라는 키워드는 명시적으로 작성할 수도 있지만, Noncopyable 타입이기 때문에 작성하지 않아도 상관없다. 컴파일러가 알아서 소유권만 옮겨준다.
system이 갖고있던 FloppyDisk의 소유권이 backup으로 옮겨진 상태이기 때문에, load(system)
함수를 호출하면 에러가 발생한다.
이제 아래와 같이 새로운 디스크를 생성해서 반환하는 함수가 있다고 가정해보자.
func newDisk() -> FloppyDisk {
let result = FloppyDisk()
format(result)
return result
}
func format(_ disk: FloppyDisk) {
}
위 코드에서 format 함수는 일반적으로 우리가 선언하는 함수의 시그니쳐를 가지고 있다. 만약 FloppyDisk가 Copyable 타입인 경우는 format 함수는 아무런 문제가 없다.
format 함수는 필요하다면 FloppyDisk의 copy본을 받아서 함수를 실행할 수 있다.
그러나 FloppyDisk는 필요한 경우 format 함수에서 인스턴스를 복사할 수 없기 때문에, format 함수가 FloppyDisk의 소유권을 어떻게 처리할지 명시해야한다.
첫 번째로 명시할 수 있는 ownership은 consume이다. consume을 사용하면 format 함수가 disk의 ownership을 가져오게 된다. 그리고 consume은 소유권을 다시 caller에게 돌려주지 않는다.
func format(_ disk: consuming FloppyDisk) {
}
format 함수가 disk의 ownership을 가지기 때문에, 일반적인 함수와 달리 format 함수에서 disk를 직접 수정할 수도 있다.
func format(_ disk: consuming FloppyDisk) {
disk.format = "NTF32"
}
하지만.. format 함수를 실행한 이후에도 result는 사용해야하기 때문에, value의 ownership을 다시 caller에게 돌려주지 않는 consuming ownership은 format 함수에 적합하지 않다.
두 번째로 명시할 수 있는 ownership은 borrow이다. borrow를 사용하면 format 함수가 disk의 ownership을 잠시 빌려온다. 함수를 종료하고나면, 소유권을 다시 caller에게 돌려준다.
func format(_ disk: borrowing FloppyDisk) {
}
ownership을 잠시 빌려오는 것이기 때문에, format 함수에서는 disk에 대해 read only access만 허용한다.
consuming과의 차이점은, borrowing한 argument는 수정할 수 없고, copy만 할 수 있는데, 알다시피 FloppyDisk는 Noncopyable이기 때문에 복사할 수 없다.
마지막으로 명시할 수 있는 ownership은 inout이다. inout은 caller의 변수에 임시 write-access를 제공하며, 함수가 종료된 후 FloppyDisk의 소유권을 다시 caller에게 돌려준다.
func format(_ disk: inout FloppyDisk) {
disk.format = "NTF32"
}
즉 format 함수는
FloppyDisk에 대한 onwership은 mutating이 맞다.
위에서 배운 Noncopyable, Ownership 내용을 토대로, BankTranser 예제를 다시 수정해보자. 마지막으로 작성했던 BankTransfer 코드는 아래와 같다.
final class BankTransfer {
private var completed = false
func run() {
guard !completed else {
return
}
// ...
completed = true
}
func cancel() {
//...
}
deinit { cancel() }
}
요구사항은 아래와 같다.
struct BankTransfer: ~Copyable {
consuming func run() {
//...
}
}
변경점은 아래와 같다.
이제 BankTransfer는 복사될 수 없기 때문에 항상 unique하며, run 메소드를 두 번 이상 절대 호출할 수 없기 때문에 complete 프로퍼티를 활용한 assertion도 필요없다.
Noncopyable한 BankTransfer의 인스턴스가 해제되는 시점도 이제 명확하기 때문에.. struct 임에도 불구하고 deinit을 활용할 수 있다.
struct BankTransfer: ~Copyable {
consuming func run() {
//...
}
consuming func cancel() {
//...
}
deinit { cancel() }
}
그러나 이렇게되면 run 함수를 정상적으로 실행했음에도 불구하고 deinit으로 인해 Transfer가 cancel 될 수 있는데.. 이것을 방지하기 위해 swift 5.9에선 discard self
라는 키워드를 소개한다.
discard self
는 Noncopyable type의 consuming 함수에서만 사용할 수 있으며, deinit 호출없이 인스턴스를 해제한다.
struct BankTransfer: ~Copyable {
consuming func run() {
//...
discard self
}
consuming func cancel() {
//...
}
deinit { cancel() }
}
BankTransfer는 지금 deinit 시점에 cancel 함수를 호출하고 있다. 그러나 cancel 함수에선 discard self
를 호출해주지 않는데, 이렇게 되면 deinit - cancel - deinit - cancel... 무한 루프에 빠지게 된다.
따라서 consuming cancel 함수에서도 discard self
를 호출해주자
요구사항에 맞게 BankTransfer 타입도 잘 리팩토링했기 때문에, 버그를 만들던 schedule 함수도 리팩토링해보자.
func schedule(
_ transfer: BankTransfer,
_ delay: Duration
) async throws {
if delay < .seconds(1) {
transfer.run()
}
try await Task.sleep(for: delay)
transfer.run()
}
schedule 함수는
두 가지 버그가 있었다.
우선 NonCopyable 타입을 parameter로 받는 경우에, 소유권을 어떻게 처리해야할지 명시해야한다. 이 함수에선 BankTransfer를 consume 할 것이기 때문에 consuming 키워드를 추가하자.
func schedule(
_ transfer: consuming BankTransfer, // 'transfer' consumed more than once
_ delay: Duration
) async throws {
if delay < .seconds(1) {
transfer.run()
}
try await Task.sleep(for: delay)
transfer.run()
}
consuming 키워드를 추가하면, 컴파일러가 BankTransfer가 두 번 이상 consume 되었다고 알려주는데, 1번 이슈를 컴파일러 단에서 체크하고 수정을 요구하는 것이다. 이 경우엔 if 문 안에서 return을 추가하면 된다.
func schedule(
_ transfer: consuming BankTransfer,
_ delay: Duration
) async throws {
if delay < .seconds(1) {
transfer.run()
return
}
try await Task.sleep(for: delay)
transfer.run()
}
이제 1번 이슈가 해결되었을 뿐만 아니라, 2번 이슈도 schedule이 NonCopyable 타입인 Transfer를 사용하는 마지막 owner이기 때문에, 혹여나 Task가 취소되어 Task.sleep이 에러를 뱉더라도, Transfer의 deinit 함수가 호출되어 적절히 cancel된다.
consume은 소유권을 이전하는 키워드이고, 실제로 BankTransfer의 경우 consuming function인 run을 호출하면, 더 이상 run을 다시 호출할 수는 없었다.
그러나 이건 BankTransfer가 Noncopyable이기 때문이고, 만약 BankTransfer가 Copyable한 객체라면 이야기가 달라진다.
struct BankTransfer {
consuming func run() {}
consuming func cancel() {}
}
func schedule(
_ transfer: consuming BankTransfer,
_ delay: Duration
) async throws {
if delay < .seconds(1) {
transfer.run()
return
}
try await Task.sleep(for: delay)
transfer.run()
}
func main() async throws {
let bankTransfer = BankTransfer()
// consuming function에 두 번이나 같은 Value를 넘겼는데.. 아무런 에러가 발생하지 않는다.
try await schedule(bankTransfer, .seconds(1))
try await schedule(bankTransfer, .seconds(2))
}
위 예제에선 BankTransfer가 Copyable한 타입으로 변경되었다.
그리고 BankTransfer를 consume하는 schedule 함수를 두 번이나 호출하지만 아무런 컴파일 에러가 발생하지 않는다.
이유가 뭘까? 왜냐하면 컴파일러가 schedule 함수엔 bankTransfer
의 복사본을 넘기기 때문이다..
좀 더 이해하기 위해서 다른 예제를 살펴보자.
func testConsuming() {
var array = [1,2,3,4,5]
append6ToArray(array)
print(array) // [1, 2, 3, 4, 5]
}
func append6ToArray(_ arr: consuming [Int]) {
arr.append(6) // [1, 2, 3, 4, 5, 6]
print(arr)
}
위 코드에서 매개변수로 넘어온 array를 consuming 하는 append6ToArray에 array를 넘기고 난 뒤, 배열의 요소를 출력해보면, testConsuming
함수의 array는 아무런 변화가 없는 것을 볼 수 있다. 즉 array
의 복사본이 append6ToArray
함수로 넘어간 것이다.
swift evolution 문서에서도 이에 대해 설명하고 있다.
그러나 여기서 우리가 append6ToArray로 넘길 때, 직접 array
를 consume 하면 정상적으로 array
의 copy본이 생성되지 않고 소유권이 함수로 이전된다.
또한 아래 예제를 봐도 Copyable한 타입의 consuming, mutating 함수가 어떻게 동작하는지 볼 수 있다.
struct OnwershipTest {
var value: Int = 10
consuming func addValueWithConsuming() {
value += 1
}
mutating func addValueWithMutating() {
value += 1
}
}
var ownershipTest = OnwershipTest()
ownershipTest.addValueWithConsuming()
print(ownershipTest.value) // 10
ownershipTest.addValueWithConsuming()
print(ownershipTest.value) // 10
ownershipTest.addValueWithConsuming()
print(ownershipTest.value) // 10
ownershipTest.addValueWithMutating()
print(ownershipTest.value) // 11
즉 consuming function은 항상 매개변수로 전달되는 타입의 소유권을 가져와서 인스턴스를 해제하지만 전달되는 타입이 Copyable이면 복사본을 가져와서 consume 해버리고.. (사실상 거의 의미가 없는 것 같다). 타입이 Noncopyable이면 caller의 데이터를 copy하지않고 본래 데이터의 소유권을 직접 가져와서 해제해버린다.
원래 Noncopyable type을 generic에 쓰지 못했는데, swift 6부터 가능하다. Noncopyable generic은 swift의 기존 generic model 위에 지어졌기 때문에, 기존 generic에 대해 먼저 상기해보자.
이제 Noncopyable generic에 대해서 알아보자.
~Copyable
을 사용해 Copyable constraint를 없애는 것이다.여기선 두 번째 옵션을 사용한다.
하지만 여기서 Job의 Action으로 여전히 Copyable & Runnable 타입인 Command를 넣을 수 있다. 또한 이전에 ~Copyable이란 키워드가 타입을 무조건 Noncopyable로 만드는게 아니고 Copyable constraint를 없애주는 것임을 봤기 때문에, 만약 Action이 Copyable 하다면 Job도 Copyable 하게 만들고 싶을 수도 있다.
그걸 이해하기 위해 아래 예제를 보도록 하자
func runEndlessly(_ job: Job<Command>) {
while true {
let current = job
current.action?.run()
}
}
extension Job: Copyable where Action: Copyable {}
이제 Job<Command>
는 Copyable 하기 때문에, runEndlessly 함수도 정상적으로 컴파일된다.
이번에는 NonCopyable type의 extension에 대해서 알아보자. 이를 위해, Job의 action을 반환하는 getter 메소드를 만들어보자.
struct Job<Action: Runnable & ~Copyable>: ~Copyable {
var action: Action?
}
extension Job { // where Action: Copyable
func getAction() -> Action? {
return action
}
}
// 1
func inspectCmd(_ cmdJob: Job<Command>) {
let a = cmdJob.getAction()
let b = cmdJob.getAction()
}
// 2
func inspectXfer(_ transferJob: borrowing Job<BankTransfer> {
let a = transferJob.getAction() // method requires that `BankTransfer` conform to `Copyable`
}
새로 생성한 getAction() 이라는 함수는 extension에 속한 함수이다. swift는 모든 곳에 Copyable을 붙인다고 했기 때문에, getAction을 선언한 extension엔 사실 Action이 Copyable하다는 표현이 숨겨져있다.
따라서 getAction은 Action이 Copyable한 타입일 때만 사용할 수 있는 함수인 것이다.
1번 함수를 보면, Command는 Copyable한 타입이기 때문에 정상적으로 컴파일 되는 반면, 2번 함수의 경우 BankTransfer가 NonCopyable 타입이기 때문에, getAction 함수를 호출할 수 없다.
여기서 말하고자 하는 의도는, 특정 타입의 extension에서 Generic parmaeter엔 항상 Copyable이 붙는다는 사실이다. 또한 해당 extension에서는 Self도 또한 Copyable한 제약조건이 암시적으로 추가된다.
extension에서 Self와 generic parameter에 자동으로 Copyable한 특성이 붙기 때문에 우리는 Job을 손쉽게 사용할 수 있다.
예를 들어서 Job이 우리가 작성한 타입이 아니고, JobKit이라는 모듈에 있는 타입이라고 가정해보자.
이제 Copyable한 프로토콜인 Cancellable을 선언하고, extension을 사용해 Job 타입이 해당 프로토콜을 채택하도록 만들어보자.
extension protocol conformance는 아무런 에러없이 정상적으로 작동하는데, 그 이유는 Job의 extension에 Action이 Copyable하다는 제약조건이 암시적으로 추가되기 때문이다. Job은 Action이 Copyable 하다면 자신또한 Copyable한 타입이 되므로, 문제없이 Copyable한 Cancellable 프로토콜을 채택할 수 있는 것이다.
만약 Cancellble이라는 프로토콜 또한 ~Copyable을 사용해 제약조건을 없애버린다면, 코드를 단순히 아래와 같이 수정하면 된다.
~Copyable
은 타입을 무조건 Noncopyable로 만드는게 아니라.. Copyable constraint를 없애는 것뿐이다.