Skip to content

Noncopyable Type에 대해 알아보자. #92

@withseon

Description

@withseon

Noncopyable type에 대해 알아보자.

이번 WWDC24에서 Swift 6에 대한 설명을 하면서 나온 noncopyable type..! 에 대해 간단히 알아보자.

Noncopyable type은 Swift 5.9에서 처음 등장하였으며, 자세한 내용은 [Swiftlang Github] Noncopyable structs and enums에서 확인이 가능하다.


Noncopyable type이란 무엇일까?

말 그대로 복사가 불가능한 타입이다.
Swift의 모든 타입은 복사가 가능한데, 그 중 구조체와 열거형은 값 복사가 이루어지기 때문에 고유 리소스에 대해서 좋은 모델이 아니라고 한다.

따라서, 이를 noncopyable하게 만듦으로써
고유한 iddeinit를 가지고 있을 수 있도록 한다는 게 가장 큰 특징이다.


Noncopyable type의 사용

복사 불가능한 구조체를 선언하는 방법은 다음과 같다.

struct FileDescriptor: ~Copyable {
	private var fd: Int32
	
	init(fd: Int32) { self.fd = fd }
	
	func write(buffer: Data) {
	    buffer.withUnsafeBytes { 
			write(fd, $0.baseAddress!, $0.count)
	    }
	}
	deinit {
		close(fd)
	}
}
  • FileDescriptor 선언 시 ~Copyable를 채택함으로써 복사가 불가능하게 만들 수 있다.
  • deinit 또한 작성할 수 있다.

Noncopyable type을 저장 속성으로 가지고 있거나, 연관값으로 갖는 타입 또한 ~Copyable로 선언해야 한다.

struct SocketPair: ~Copyable {
	var in, out: FileDescriptor
}

enum FileOrMemory: ~Copyable {
	// write to an OS file
	case file(FileDescriptor)
	// write to an array in memory
	case memory([UInt8])
}

// ERROR: copyable value type cannot contain noncopyable members
struct FileWithPath {
	var file: FileDescriptor
	var path: String
}
  • FileDescriptor를 저장 속성으로 갖는 구조체의 경우 ~Copyable로 선언해주어야 한다.
  • FileDescriptor를 연관값으로 갖는 열거형의 경우 ~Copyable로 선언해주어야 한다.
// ERROR: classes must be `Copyable`
class SharedFile: ~Copyable {
	var file: FileDescriptor
}
  • 하지만 클래스의 경우는 Copyable를 따르기 때문에 ~Copyable로 선언하게 되면 에러가 난다.

그렇다면, 복사가 이루어지지 않는다는 게 뭘까?
Noncopyable type으로 선언된 구조체를 예시로 가져와봤다.

struct User: ~Copyable {
	var name: Stirng
}

func createUser() {
	let user = User(name: "Anonymous")
	let newUser = user
	
	print(newUser.name)
	print(user.name) // ERROR! 'user' used after consume
}
  • newUser를 copyUser에 할당한 후, print문으로 newUser의 name을 출력하려고 하면 다음과 같이 에러가 난다.
  • 이는 newUser로 생성한 인스턴스의 소유권이 copyUser로 넘어갔기 때문에 더는 newUser를 사용할 수 없음을 뜻한다.

아까 deinit을 생성할 수 있다고 했는데, 그럼 deinit이 호출되는 순간은 언제일까?
소유권을 넘겨주면서 deinit이 호출되는지 확인해보았다.

struct User: ~Copyable {
	var name: String
	
	deinit {
		print("해제")
	}
}

func f() {
	var user = User(name: "Taco")
	print(user.name)
	
	var newUser = user
	newUser.name = "타코"
	print(newUser.name)
}

f()

// Taco
// 타코
// 해제
  • newUser에 user에 대한 소유권을 넘겨줄 때는 user에 대한 deinit이 호출되지 않는다.
  • 함수가 종료되면서 newUser에 대한 deinit이 호출됨을 알 수 있다.

그렇다면, 복사되지 않는 타입이라는 것은 값을 재할당하는 게 아니라 넘겨줌으로써 인스턴스를 하나로 유지하는 것이라고 생각된다.


여기서, 함수의 매개변수에 대한 의문이 생길 수 있다.
우리는 함수 내에 상수를 생성한 후 매개변수의 값을 복사하여 사용한다는 것을 알고 있는데, Noncopyable type은 복사하지 못하면... 어떤 식으로 동작하는 것일까?

일단 우리가 평소에 쓰는 함수의 매개변수 형태로 코드를 작성해보았다.

func createAndGreetUser() {
	let newUser = User(name: "Taco")
	greet(newUser)
	print("Good Bye, \(newUser.name)")
}

func greet(_ user: User) { // ERROR! Noncopyable parameter must specify its ownership
	print("Hello, \(user.name)")
}

createAndGreetUser()
스크린샷 2024-06-13 오후 5 23 15

위처럼 에러가 나게 된다.
Noncopyable type은 값을 빌려서 사용하거나, 값 자체를 바꾸거나, 소유권을 이전하는 방식으로 동작해야 한다고 한다.

inout 방식은 다들 알 거라고 생각해서 넘어간다.

// borrowing 키워드를 사용하여 값을 빌려 사용
func greet(_ user: borrowing User) {
	print("Hello, \(user.name)")
}

// consuming 키워드를 사용하여 소유권을 이전
func greet(_ user: consuming User) {
	print("Hello, \(user.name)")
}

다음과 같이 borrowing 키워드를 사용하는 방식과 consuming을 사용하는 방식이 있다.

  • borrowing 키워드는 '값을 빌려서 사용'하게 되며, 이는 read-only로 동작하게 된다.
  • consuming 키워드는 '소유권을 이전'하게 되며, consuming으로 값을 넘겨주며 함수를 호출한 후에는 해당 인스턴스에 대한 접근이 불가능하다. (여기서는 greet(newUser)로 호출하였기 때문에, 호출 후 newUser를 사용할 수 없게 된다.)

consuming 키워드를 메서드 선언 시에도 사용할 수 있는데,
이 때는 함수 호출을 한 번으로 제한하며, 함수가 호출된 후 소멸자가 호출되게 된다.

consuming으로 선언된 메서드의 동작을 확인해보면 다음과 같다.

struct SecretMessage: ~Copyable {
	private var message: String
	
	init(message: String) {
		self.message = message
	}
	
	consuming func readMessage() {
		print(message)
	}

	deinit {
		print("해제")
	}
}
func makeMessage() {
    let message = SecretMessage(message: "Hello")
    message.readMessage()
    message.readMessage() // ERROR. 'message' consumed more than once
}

makeMessage()
func makeMessage() {
    let message = SecretMessage(message: "Hello")
    message.readMessage()
    let newMessage = message //  ERROR. 'message' consumed more than once
}

makeMessage()
  • 다음과 같이, 메서드를 두 번 호출하거나 이미 한 번 호출한 후 다른 변수에 소유권을 넘겨주고자 하면 에러가 발생한다.
  • 함수의 호출이 한 번으로 제한되는 것을 확인할 수 있다.

func makeMessage() {
    let message = SecretMessage(message: "Hello")
    message.readMessage()
    print("함수 종료")
}

makeMessage()
스크린샷 2024-06-20 오후 10 28 27
  • consuming으로 선언한 메서드가 호출된 후 소멸자가 호출되는 것을 확인할 수 있다.

하지만, 이렇게 메서드 호출 후 바로 deinit이 되는 상황을 막을 수 있는 방법이 있다.
바로 discard self를 사용하는 것이다.

struct Score: ~Copyable {
    private var score = 0
    
    init(score: Int = 0) {

        self.score = score

    }

    consuming func register() {
        print(self.score)
        discard self
    }

    deinit {
        print("해제")
    }

}

func createScore() {
    let score = Score(score: 60)
    score.register()
}

createScore()
스크린샷 2024-06-20 오후 8 54 23
  • 위와 같이 "해제"는 출력되지 않는다.
  • 그렇지만, consuming으로 선언된 메서드를 두 번 호출하거나 한 번 호출한 뒤 변수에 소유권을 넘겨주려고 하면 동일한 에러가 발생한다. (consuming 메서드가 존재하면 호출 후에 넘겨줄 수 없는 듯..)
  • consuming 메서드를 호출한 뒤, 다른 메서드를 호출하는 경우에도 동일한 에러가 발생한다.
  • 그럼 왜쓰는거야..

읽을 거리

[Hacking With Swift] Noncopyable structs and enums
[Swiftlang Github]Noncopyable structs and enums

이번 WWDC24 세선 (6/13)
WWDC2024 Consume noncopyable types in Swift

Swift 6에서는 모든 제네릭 타입에 대해 Noncopyable이 가능하다고 하던데....
[Swiftlang Github] Noncopyable Generic


consume 키워드를 사용하여 바인딩하기
[Hacking With Swift] consume operator to end the lifetime of a variable binding


Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions