반응형

Hacking with Swift 사이트의 강좌 번역본입니다.

[원문 : https://www.hackingwithswift.com/articles/258/whats-new-in-swift-5-9]

What’s new in Swift 5.9

if와 switch 표현(ifand switchexpressions)

SE-3080 if와 swiftch를 여러가지 상황에서 사용할 수 있는 기능을 추가했습니다. 처음에는 약간 놀라운 구문을 만들지만, 전반적으로는 언어에서 약간의 추가 구문을 줄이는데 도움을 줍니다. 

간단한 예제로, 다음과 같은 조건에 따라 변수를 Pass 또는 Fail 으로 설정할 수 있습니다.

let score = 800
let simpleResult = if score > 500 { "Pass" } else { "Fail" }
print(simpleResult)

또는 switch 표현식을 사용해서 다음과 같이 더 넓은 범위의 값을 사용할 수 있습니다.

let complexResult = switch score {
    case 0...300: "Fail"
    case 301...500: "Pass"
    case 501...800: "Merit"
    default: "Distinction"
}

print(complexResult)

새로운 표현 구문을 사용하면 결과를 어딘가에 할당할 필요가 없고, Swift 5.1의 SE-0255와 아름답게 결합되어, 값을 반환하는 단일 표현 함수로 return 키워드를 생략할 수 있습니다.

따라서, 이제 if와 switch 모두 표현식으로 사용될수 있기에, 4가지 경우가 가능한 경우에 return을 사용하지 않고 다음과 같은 함수를 사용할 수 있습니다.

func rating(for score: Int) -> String {
    switch score {
    case 0...300: "Fail"
    case 301...500: "Pass"
    case 501...800: "Merit"
    default: "Distinction"
    }
}

print(rating(for: score))

if의 기능이 삼항 연산자처럼 동작한다고 생각할 수 있고, 부분적으로는 그게 맞습니다. 예를 들어, 이전의 if 조건문을 다음과 같이 간단하게 사용할 수 있습니다.

let ternaryResult = score > 500 ? "Pass" : "Fail"
print(ternaryResult)

하지만 이 둘은 동일하지 않고, 특히 한가지가 다르며 - 다음 코드에서 확인할 수 있습니다.

let customerRating = 4
let bonusMultiplier1 = customerRating > 3 ? 1.5 : 1
let bonusMultiplier2 = if customerRating > 3 { 1.5 } else { 1.0 }

두 가지 계산 모두 Double값인 1.5 값을 생성하지만, 각각의 대체값을 주의해야 합니다 : 삼항연산자는 1을, if 표현식은 1.0을 사용했습니다.

이는 의도된 것입니다: 삼항 연산자를 사용할때 동시에 두 값들의 타입을 확인하고 자동으로 1을 1.0으로 간주하는 반면, if 표현식은 2가지 옵션을 독립적으로 확인합니다. : 만약 하나에 1.5를 사용하고 다른 하나에는 1을 사용하면, Double과 Int를 반환하지만, 허용되지 않습니다.

값과 타입 매개변수 팩(Value and Type Parameter Packs)

Swift의 개선된 가변 재네릭을 사용하기 위해 SE-0393, SE-0398, SE-0399를 결 했습니다. 

이는 상당히 진보된 기능이므로, 대부분의 사람들을 위해 요약합니다. : SwiftUI의 예전 10개 뷰 제한이 곧 사라지게 될 것이 거의 확실시됩니다. 

이러한 제안들은 Swift에서 중요한 문제를 해결하며, 제네릭한 함수에는 타입 매개변수의 특정 갯수가 필요합니다. 기존에도 가변 매개변수를 허용하지만, 궁극적으로 동일한 타입을 사용해야 했습니다.

예를들어, 프로그램의 다른 부분을 나타내는 3가지 다른 구조체가 있습니다.

struct FrontEndDev {
    var name: String
}

struct BackEndDev {
    var name: String
}

struct FullStackDev {
    var name: String
}

실제로 이러한 타입을 고유(unique)하게 만드는 프로퍼티가 훨씬 많겠지만, 3가지 타입이 있다는 것을 이해할 수 있습니다.

다음과 같이 해당 구조체의 인스턴스를 만들수 있습니다.

let johnny = FrontEndDev(name: "Johnny Appleseed")
let jess = FrontEndDev(name: "Jessica Appleseed")
let kate = BackEndDev(name: "Kate Bell")
let kevin = BackEndDev(name: "Kevin Bell")

let derek = FullStackDev(name: "Derek Derekson")

실제로 작업할때 다음과 같이 간단한 함수를 사용해서 개발자들간에 사용 할 수 있습니다.

func pairUp1<T, U>(firstPeople: T..., secondPeople: U...) -> ([(T, U)]) {
    assert(firstPeople.count == secondPeople.count, "You must provide equal numbers of people to pair.")
    var result = [(T, U)]()

    for i in 0..<firstPeople.count {
        result.append((firstPeople[i], secondPeople[i]))
    }

    return result
}

두개의 가변 매개변수들을 사용해서 첫번째 그룹과 두번째 그룹을 받고나서 하나의 배열을 반환합니다.

백앤드(back-end)와 프런트앤드(front-end) 작업을 함께 할 수 있는 프로그래머 쌍을 만들 수 있습니다.

let result1 = pairUp1(firstPeople: johnny, jess, secondPeople: kate, kevin)

이는 오래되었지만, 재미있는 것은 다음과 같습니다: 데릭(Deeck)은 풀스택(full-stack) 개발자이고, 백앤드 개발자 또는 프론트 앤드 개발자로 일할 수 있습니다. 하지만 첫번째 매개변수로 johnny, derek를 사용하려는 코드를 Swift는 빌드되지 않을 것입니다.. - 첫번째 사람과 두번째 사람의 타입이 같아야 합니다. 

이를 해결하는 한가지 방법은 Any를 사용해서 모든 타입의 정보를 처리하는 것이지만, 매개변수 팩을 사용하면 훨씬 더 우아하게 문제를 해결할 수 있습니다.

구문이 처음에는 약간 복잡할수 있으므로, 코드부터 보고나서 분석하도록 하겠습니다. 

func pairUp2<each T, each U>(firstPeople: repeat each T, secondPeople: repeat each U) -> (repeat (first: each T, second: each U)) {
    return (repeat (each firstPeople, each secondPeople))
}

이는 4가지 독립적인 일을 하므로 하나씩 살펴보겠습니다.

  1. <each T, each U>는 T와 U 2개의 타입 매개변수 팩을 만듭니다.
  2. repeat each T는 매개변수 팩을 실제 값으로 확장하는 팩 확장입니다. - T... 와 같지만, ...연산자로 사용되는 것과의 혼동을 피할 수 있습니다.
  3. 반환타입은 T와 U 각각 하나씩 짝을 이룬 프로그래머들의 튜플을 다시 보내는 것을 의미합니다. 
  4. return 키워드는 실제 작업을 합니다. : 팩 확장 표현식을 사용해서 하나의 값 T와 하나의 값 U를 가져와서 반환된 값에 함께 넣습니다. 

보여지지 않는 것은 반환 타입이 자동으로 T와 U타입이 동일한 모양을 가지는 것을 보장합니다. - 내부에 같은 항목 수를 가집니다. 따라서 첫번째 함수에서 사용한것 처럼 assert() 를 사용하는 대신에, Swift는 다른 크기의 2세트 데이터를 전달하면 컴파일러 오류가 발생 할 것입니다. 

새로운 함수를 사용하면, 다음과 같이 데릭(Derek)을 다른 개발자와 함께 할 수 있습니다. 

let result2 = pairUp2(firstPeople: johnny, derek, secondPeople: kate, kevin)

지금 우리가 실제로 작업한 것은 zip() 함수를 구현한 것입니다.

let result3 = pairUp2(firstPeople: johnny, derek, secondPeople: kate, 556)

캐빈(Kevin)과 숫자 556를 연결하려고 하는데, 말이 되지 않습니다. 이는 프로토콜을 정의할 수 있기때문에, 매개변수 팩이 실제로 사용됩니다. 

protocol WritesFrontEndCode { }
protocol WritesBackEndCode { }

그리고나서 다음 몇가지 적합성을 추가합니다.
- FrontEndDev는 WriteFrontEndCode를 준수해야 합니다.
- BackEndDev는 WritesBackEndCode를 준수해야 합니다.
- FullStackDev는 WritesFrontEndCode와 WriteBackEndCode를 준수해야 합니다.

이제 타입 매개변수 팩에 제약조건을 추가할 수 있습니다.

func pairUp3<each T: WritesFrontEndCode, each U: WritesBackEndCode>(firstPeople: repeat each T, secondPeople: repeat each U) -> (repeat (first: each T, second: each U)) {
    return (repeat (each firstPeople, each secondPeople))
}

이제 잘 알고 있는 짝을 지어 줄 수 있다는 것을 의미합니다. - 풀스택 개발자인지와 상관없이, 프론트 앤드 코드를 작성할 수 있는 사람과 백앤드 코드를 작성할 수 있는 사람을 항상 짝지어 줍니다. 

SwiftUI에서 비슷한 상황이 있기에, 이에 대한 더 풍부한 경험을 할수 있습니다. 많은 하위 뷰를 만들 수 있길 원하며, Text 처럼 단일 뷰로 작업하는 경우에 Text...을 상상할 수 있습니다. 하지만, 텍스트, 이미지, 버튼 등은 이 방법을 사용할 수 없습니다 - 균일하지 않는 레이아웃은 사용할 수 없습니다. 

AnyView...를 사용하거나 타입 정보를 지우는 것과 비슷한 방식을 사용하므로, Swift 5.9 이전에는 이를 해결하기 위해서 많은 과부하 함수를 사용했습니다. 예를들어, SwiftUI의 뷰는 buildBlock()오버로드가 있어서, 2개 뷰, 3개 뷰, 4개 뷰, 등을 최대 10개까지 결합할 수 있었습니다 - 하지만 기준이 필요하기 때문에 더이상은 안됩니다. 

따라서, SwiftUI에서 10개 뷰 제한을 두게 되었습니다. 그리고 곧 사라지길 희망합니다(fingers crossed).

매크로(Macros)

Swift에 컴파일 시간에 구문을 변환하는 코드를 만들기 위한, 매크로를 추가하기 위해서 SE-0382, SE-0389, SE-0397을 결합했습니다. 

C++ 에서의 매크로는 코드를 전처리(pre-process)하는 방법입니다. - 즉, 메인 컴파일러가 동작하기 전에 코드에서 텍스트 교체를 수행해서 손으로 작업하고 싶지 않은 코드들을 생성할 수 있습니다. 

Swift의 매크로는 비슷하지만, 훨씬 더 강력합니다. - 따라서 더 복잡합니다. 또한 프로젝트의 Swift코드가 컴파일 되기 전에 동적으로 조작 가능하므로, 컴파일 시간에 추가적인 기능을 넣을 수 있습니다. 

알아야 할것은 다음과 같습니다. 
- 단순한 문자열 교체가 아니고, 타입에 안전하므로 매크로가 동작할 데이터를 정확히 알아야 합니다.
- 빌드 단계에서 외부 프로그램 처럼 실행되고, 메인 앱 타겟에 존재하지 않습니다. 
- 매크로는 하나의 표현식을 만들기 위한 ExpressionMacro, getter와 setter를 추가하는AccessorMacro, 프로토콜 준수하는 타입을 만드는 ConformanceMacro 처럼 여러개의 작은 타입으로 나뉩니다. 
- 매크로는 분석된 소스 코드와 같이 동작합니다 - 조작중인 프로퍼티의 이름이나 타입, 또는 구조체 내부의 다양한 프로퍼티들 처럼 코드를 개별적으로 조회할 수 있습니다. 
- 샌드박스(sendbox) 내부에서 동작하고 주어진 데이터에서만 작동해야 합니다.

마지막 부분이 특히 중요합니다. Swift의 매크로는 소스코드를 이해하고 조작하기 위해서 애플의 SwiftSyntax 라이브러리를 중심으로 만들어졌습니다. 매크로에 대한 종속성으로 이를 추가해야 합니다.

간단한 매크로로 시작하며, 어떻게 동작하는 볼 수 있을 것입니다. 매크로는 컴파일 시간에 실행되기 때문에, 앱이 빌드된 날짜와 시간을 반환하는 작은 매크로를 만들수 있습니다. - 디버그 진단하는데 도움이 될것입니다. 여기에는 여러단계가 있으며, 그 중 일부는 메인 타겟으로부터 별도의 모듈에서 수행되어야 합니다.

우선 매크로 확장을 수행하는 코드를 만들어야 합니다. - #buildDate  2023-06-05T18:00:00Z와 같은 것으로 변환합니다.

public struct BuildDateMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        let date = ISO8601DateFormatter().string(from: .now)
        return "\"\(raw: date)\""
    }
}

중요: 이 코드는 메인 앱 타겟에 있으면 안됩니다; 완성된 앱으로 컴파일 되는것을 원치 않으며, 완성된 날짜 문자열을 원할뿐입니다.

동일한 모듈에서 CompilerPlugin 프로토콜을 준수하는 구조체를 만들고 매크로를 내보냅니다(export)

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct MyMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        BuildDateMacro.self
    ]
}

그리고나서, Packages.swift 의 타겟 목록에 추가합니다.

.macro(
  name: "MyMacrosPlugin",
  dependencies: [
    .product(name: "SwiftSyntax", package: "swift-syntax"),
    .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
    .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
  ]
),

외부 모듈에서 매크로 생성이 완료됩니다. 나머지 코드는 메인 앱 타겟과 같이 매크로를 사용하려는 모든 것에서 실행됩니다.

두번재 단계가 있으며, 매크로가 무엇인지에 대한 정의부터 시작합니다. 우리의 경우에 문자열을 반환하는 독립된 표현식 매크로이며, MyMacrosPlugin 모듈내부에 존재하고, BuildDateMacro라는 엄격한 이름을 가지고 있습니다. 따라서, 메인 타겟에 해당 정의를 추가합니다

@freestanding(expression)
macro buildDate() -> String =
  #externalMacro(module: "MyMacrosPlugin", type: "BuildDateMacro")

두번째 단계는 실제로 다음과 같이 사용하는 것입니다.

print(#buildDate)

이 코드를 읽을때 가장 중요한 것은 메인 매크로 기능(BuildDateMacro 구조체 내부의 모든 코드)은 빌드 시간에 실행되며, 결과가 호출한 곳에 다시 주입됩니다. 따라서 print() 작은 호출로 다음과 같이 다시 작성됩니다.

print("2023-06-05T18:00:00Z")

이는 매크로 내부의 코드 만큼의 복잡할 수 있다는 것을 의미합니다. 완료된 코드로 보이는 것은 우리가 반환한 문자열이기 때문에 ,원하는 방법으로 날짜를 만들수 있습니다. 

조금 더 유용한 매크로를 사용해봅시다. 이번에는 멤버 속성 매크로를 만듭니다. 클래스 같은 타입을 정의할때 클래스의 모든 멤버에 속성을 적용할 수 있습니다. 이는 타입의 각 속성에 @objc를 추가하는 예전의 @objcMembers 속성과 동일한 개념입니다.

예를들어, 모든 속성에서 @Published를 사용하는 관찰가능한(observable) 객체가 있는 경우에, 간단하게 @AllPublished 매크로 를 작성할 수 있습니다. 먼저 매크로를 작성합니다.

public struct AllPublishedMacro: MemberAttributeMacro {
    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingAttributesFor member: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AttributeSyntax] {
        [AttributeSyntax(attributeName: SimpleTypeIdentifierSyntax(name: .identifier("Published")))]
    }
}

둘째, 제공된 매크로 목록에 다음을 포함합니다. 

struct MyMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        BuildDateMacro.self,
        AllPublishedMacro.self,
    ]
}

세번째, 메인 앱 타겟에서 매크로를 선언하고, 연결된 멤버 속성 매크로를 표시합니다.

@attached(memberAttribute)
macro AllPublished() = #externalMacro(module: "MyMacrosPlugin", type: "AllPublishedMacro")

이제 관잘 가능한 객체 클래스에 주석을 추가하는데 사용합니다.

@AllPublished class User: ObservableObject {
    var username = "Taylor"
    var age = 26
}

매크로는 동작을 제어하기 위해 매개변수를 허용할수 있지만, 실제로 복잡성이 증가하기 쉽습니다. 예를 들어, Swift 팀의 Doug Gregor는 빌드 시간에 하드코딩된 URL이 유효한지 확인하는 깔끔한 매크로를 포함한 작은 GitHub 저장소를 가지고 있습니다 - 빌드가 진행되지 않기 때문에 URL을 잘못 입력할 수 없습니다.

앱 타겟에서 매크로를 선언해서 문자열을 추가하는 것을 포함하 간단합니다. 

@freestanding(expression) public macro URL(_ stringLiteral: String) -> URL = #externalMacro(module: "MyMacrosPlugin", type: "URLMacro")

사용봅도 간단합니다.

let url = #URL("https://swift.org")
print(url.absoluteString)

컴파일 시간에 URL이 올바른지 확인할 것이기 때문에, url이 옵셔널이 아닌 전체 URL 인스턴스가 됩니다. 

어려운 것은 전달된 https://swift.org 문자열을 읽고 URL로 변환해야 하는 실제 매크로 자체입니다. Doug의 버젼은 더 철저하지만, 최소한으로 요약하면 다음과 같습니다.

public struct URLMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression,
              let segments = argument.as(StringLiteralExprSyntax.self)?.segments
        else {
            fatalError("#URL requires a static string literal")
        }

        guard let _ = URL(string: segments.description) else {
            fatalError("Malformed url: \(argument)")
        }

        return "URL(string: \(argument))!"
    }
}

SwiftSyntax는 훌륭하지만, discoverable이라고 하는 것이 아닙니다. 

계속 하기 전에 추가하고 싶은것이 3가지가 있습니다.

첫째, 주어진 MacroExpansionContenxt 값은 makeUniqueName() 메소드에서 매우 유용하며, 현재 컨텍스트의 다른 이름들과 충돌되지 않는 것을 보장하는 새로운 변수의 이름으로 만들수 있습니다.

둘째, 매크로에 대해 우려하는 것은 문제가 발생했을때 코드를 디버깅하는 기능입니다 - 코드를 쉽게 처리할 수 없을때 추적하기 어렵습니다. 리팩토링 작업으로 매크로를 확장하기 위해서 SourceKit 내부에서 이미 수행하고 있지만, 실제로 Xcode에 제공되는지 확인해야 합니다.

마지막으로, 이전에는 광범위한 컴파일러 지원과 논의하는 것이 필요했던 많은 기능을 이제 프로토타입을 만들 수 있고 매크로를 사용하여 제공할 수 있기 때문에, 매크로의 광범위한 변환은 Swift Evolution 자체가 향후 1~2년에 걸쳐 진화될 것을 의미합니다.

복사할수 없는 구조체와 열거형(Noncopyable structs and enums)

SE-03090 복사할 수 없는 구조체와 열거형의 개념을 도입해서 구조체 또는 열거형의 단일 인스턴스를 여러곳에서 공유할 수 있도록 합니다 - 여전히 한명의 소유자가 있지만, 여러곳에서 사용할 수 있습니다. 

첫째, 이러한 변경은 요구사항을 억제하기 위해서 새로운 구문을 도입합니다: ~Copyable. 이 타입은 복사할수 없습니다를 의미하고. 이 억제 구문은 현재 다른곳에서는 사용할 수없습니다. -  ~Equatable을 사용할수 없으며, 예를 들어, == 을 선택하지 않습니다.

따라서, 다음과 같이 복사할수 없는 User 구조체를 만들수 있습니다.

struct User: ~Copyable {
    var name: String
}

주의: 복사할수 없는 타입은 Sendable 이외의 프로토콜은 준수할 수 없습니다.

User 인스턴스를 한번 만들면, 복사할수 없는 특성은 이전 버젼의 Swift와 매우 다르게 사용됩니다. 예를들어, 이런 종류의 코드는 특별하지 않는 것처럼 읽을수 있습니다.

func createUser() {
    let newUser = User(name: "Anonymous")

    var userCopy = newUser
    print(userCopy.name)
}

createUser()

User 구조체를 복사할수 없다고 선언했습니다 - newUser의 복사본은 어떻게 가질수 있을까요? 그 대답은 할 수 없습니다: newUser를 userCoyp에 할당하면 원래 newUser 값이 사용되며, 이제 userCopy에 속하므로 더 이상 사용할 수 없습니다. print(userCopy.name)  print(newUser.name)으로 변경하면 Swift에서 컴파일러 오류가 발생하는 것을 볼 수 있습니다. - 허용되지 않습니다. 

복사할 수없는 타입을 함수 매개변수로 사용하는 방법에 새로운 제한사항이 적용됩니다: SE-0377은 함수는 해당 값을 사용할 의도가 있는지 명시해야 하고 함수가 종료된 후에 호출한 곳에서 그것을 무효화하거나 우리 코드의 다른 부분들과 동시에 모든 데이터를 읽을 수 있도록 할지를 명시해야 합니다. 

따라서, 사용자를 생성하는 함수 하나와 읽기전용 데이터를 사용하는 권한을 얻는 다른 함수를 작성할 수 있습니다.

func createAndGreetUser() {
    let newUser = User(name: "Anonymous")
    greet(newUser)
    print("Goodbye, \(newUser.name)")
}

func greet(_ user: borrowing User) {
    print("Hello, \(user.name)!")
}

createAndGreetUser()

대조적으로,  consuming User를 사용하는 great() 함수의 경우에, print("Goodbye, \(newUser.name)")은 허용되지 않을 것입니다 - Swift는greet()를 실행한 후에 newUser값이 유효하지 않은 것으로 간주합니다. 반대로, 객체의 수명을 종료해야 하기 때문에, 해당 속성을 자유롭게 변경할 수 있습니다. 

이러한 공유 동작은 복사할수없는 구조체 이전에는 클래스와 액터로 제한되었던 능력을 제공합니다. 복사할수 없는 인스턴스에 대한 최종 참조가 제거될때 자동으로 실행되는 메모리해제(deinitializers)를 제공할 수 있습니다.

중요: 이 동작은 초기 구현 결함이나 고의적인 동작인 클래스에서의 메모리해제(deinitializers)와는 약간 다릅니다. 

우선, 클래스의 메모리 해제(deinitializer)를 사용하는 코드는 다음과 같습니다.

class Movie {
    var name: String

    init(name: String) {
        self.name = name
    }

    deinit {
        print("\(name) is no longer available")
    }
}

func watchMovie() {
    let movie = Movie(name: "The Hunt for Red October")
    print("Watching \(movie.name)")
}

watchMovie()

실행하면 Watching The Hunt for Red October가 출력되고나서 The Hunt for Red Octoberis no longer available이 출력됩니다. 하지만 타입의 정의를 class Movie에서 struct Movie: ~Copyable로 변경하면 2개의 print() 문이 역순으로 실행되는 것을 볼 수 있습니다. 

복사불가능한 타입 내부의 메소드는 기본적으로 빌려서(borrowing) 사용되지만, 복사가능한 타입과 마찬가지로 mutating으로 표시될 수 있고, 메소드가 실행된 후 값이 유효하지 않음을 의미하기 위해 소비하는 것(consuming)으로 표시 될 수 있습니다. 

예를들어, 한번만 재생하고 자동으로 파괴되는 테이프에 비밀 요원에게 임무 지시가 주어지는 미션 임파서블(Mission Impossible) 영화와 TV 시리즈를 알수도 있습니다. 다음과 같이 소비하는 메소드에 적합합니다.

struct MissionImpossibleMessage: ~Copyable {
    private var message: String

    init(message: String) {
        self.message = message
    }

    consuming func read() {
        print(message)
    }
}

메시지 자체가 비공개로 표시되므로, 해당 인스턴스는 read() 메소드를 호출해야 사용할 수 있습니다.

메소드를 변경하는 것과는 달리 소비하는 메소드는 타입의 상수 인스턴스에서 실행할 수 있습니다. 다음과 같은 코드가 좋습니다.

func createMessage() {
    let message = MissionImpossibleMessage(message: "You need to abseil down a skyscraper for some reason.")
    message.read()
}

createMessage()

주의:  message.read()가 message 인스턴스를 소비하기 때문에, message.read()를 두번째 호출하면 오류가 납니다.

사용하는 메소드는 사용자가 수행하는 모든 작업이 두배가 될수 있기 때문에, 메모리 해제(deinitializers)와 결합하게 되면 약간 더 복잡해집니다. 예를들어, 게임에서 높은 점수를 추적하려면 소비하는 finalize() 메소드를 사용해서, 영구 스토리지에 가장 높은 점수를 기록하고 다른 사용자가 더 이상 점수를 변경하지 못하도록 할것이지만, 객체가 제거될때 디스크에 최신 점수를 저장하는 메모리 해제(deinitializer)가 있을수도 있습니다.

이 문제를 피하기 위해서, Swift 5.0는 복사할 수 없는 타입의 소비하는 메소드를 사용할 수있는 새로운 discard 연산자를 도입했습니다. 소비하는 메소드에서 discard self를 사용할때, 해당 객체에 대해 메모리 해제를 사용하지 않습니다.

따라서, HighScore 구조체를 다음과 같이 구현할 수 있습니다.

struct HighScore: ~Copyable {
    var value = 0

    consuming func finalize() {
        print("Saving score to disk…")
        discard self
    }

    deinit {
        print("Deinit is saving score to disk…")
    }
}

func createHighScore() {
    var highScore = HighScore()
    highScore.value = 20
    highScore.finalize()
}

createHighScore()

: value 프로퍼티
해당 코드가 실행되면 메모리 해제(deinitializer) 메시지가 두번 출력되는 것을 볼 수 있습니다. - 한번은 구조체가 효과적으로 제거되고 다시생성하는 value 프로퍼티가 변경될때이고, 한번은 createHighScore() 메소드가 종료될때입니다. 

이러한 새로운 기능으로 작업해야 할때 알아야할 몇가지 추가적인 복잡성이 있습니다.

  • 클래스(Classes)와 액터(Actors)는 복사할수 없도록(Noncopyable) 만들수 없습니다.
  • 이번에 복사할수 없는(Noncopyable) 타입은 제네릭은 지원하지 않으며, 당분간은 복사할수 없는 객체와 복사할수 없는 객체의 배열은 제외합니다.
  • 복사할수 없는(noncopyable) 타입을 다른 구조체와 열거형 내부의 프로퍼티로 사용하는 경우에 해당 부모 구조체와 열겨형도 반드시 복사할수 없어야(noncopyable) 합니다.
  • 기존 타입을 Copyable을 추가하거나 삭제하는 경우에 사용방법이 크게 변경되기 때문에 매우 주의해야 합니다. 라이브러리에서 코드를 넣는 경우에 ABI(Application binary interface)가 손상됩니다. 

이는 Swift에서 정말 광범위한 변화이고, 어떻게 사용될지 정말 궁금합니다. Swift Data가 아니라면 실망할것 같습니다.

변수 바인딩의 수명을 종료하는 소비하는 연산자(consume operator to end the lifetime of a variable binding)

SE-0366은 소비하는 값의 개념을 복사할수 있는(copyable) 타입의 로컬 변수와 상수로 확장해서, 데이터가 전돨될때 과도하게 유지(retain)/해제(release) 호출을 피하는데 도움을 줄수 있습니다.

가장 간단한 형태의, consume 연산자는 다음과 같습니다.

struct User {
    var name: String
}

func createUser() {
    let newUser = User(name: "Anonymous")
    let userCopy = consume newUser
    print(userCopy.name)
}

createUser()

여기에서 중요한 것은 let userCopy가 있는 줄이며, 한번에 2가지 일을 하고 있습니다. 

  1. 값을 newUser에서 userCopy로 복사합니다. 
  2. newUser의 수명을 종료하므로, 더 이상 사용하려고하면 오류가 발생합니다. 

이렇게 하면 컴파일러에 명시적으로 값을 다시 사용하는 것을 허용하지 않습니다라는 것을 알릴수 있고 컴파일러는 우리를 대신해서 규칙을 시행합니다.

소위 블랙홀이라고 하는 흔히 볼수 있는 _은 데이터의 복사본을 원하지 않고 다음과 같이 단순히 제거되는 것을 표시합니다.

func consumeUser() {
    let newUser = User(name: "Anonymous")
    _ = consume newUser
}

그러나 실제로 consume 연산자가 사용되는 가장 일반적인 곳은 다음과 같이 함수에 값을 전달할때일 수 있습니다.

func createAndProcessUser() {
    let newUser = User(name: "Anonymous")
    process(user: consume newUser)
}

func process(user: User) {
    print("Processing \(name)…")
}

createAndProcessUser()

이 기능에 대해서 특히 알아야할 2가지 추가사항이 있습니다.

첫번째, Swift는 값을 소비하는 코드의 분기를 추적하고, 조건부로 규칙을 시행합니다. 따라서, 해당 코드는 두가지 가능성 중 하나만 User 인스턴스를 사용합니다.

func greetRandomly() {
    let user = User(name: "Taylor Swift")

    if Bool.random() {
        let userCopy = consume user
        print("Hello, \(userCopy.name)")
    } else {
        print("Greetings, \(user.name)")
    }
}

greetRandomly()

두번째, 기술적으로 말하면consume는 바인딩(bindings)에서 동작합니다. 실제로 변수를 사용해서 소비하는 경우에, 변수를 다시 초기화 하고 잘 사용할 수 있음을 의미합니다.

func createThenRecreate() {
    var user = User(name: "Roy Kent")
    _ = consume user

    user = User(name: "Jamie Tartt")
    print(user.name)
}

createThenRecreate()

편리한 Async[Throwing] Stream.makeStream (Convenience Async[Throwing]Stream.makeStream methods)

[SE-0388]https://github.com/apple/swift-evolution/blob/main/proposals/0388-async-stream-factory.md)은 AsyncStream과 AsyncThrowingStream 모두 연속적으로 스트럼 자체를 다시 보내는 새로운 makeStream() 메소드를 추가했습니다. 

따라서, 다음과 같이 코드를 작성하는 대신에 

var continuation: AsyncStream<String>.Continuation!
let stream = AsyncStream<String> { continuation = $0 }

이제 동시에 2가지를 얻을 수 있습니다.

let (stream, continuation) = AsyncStream.makeStream(of: String.self)

이는 다른 함수처럼, 현재 컨텍스트 외부에서 연속적으로 사용해야 하는 곳에서 특히 환영받을 것입니다. 예를들어, 이전에 다음과 같이 간단한 숫자 생성기를 작성했을수 있으며, queueWork() 메소드에서 순서대로 호출할수 있도록 자체 프로퍼티를 연속해서 저장해야 합니다. 

struct OldNumberGenerator {
    private var continuation: AsyncStream<Int>.Continuation!
    var stream: AsyncStream<Int>!

    init() {
        stream = AsyncStream(Int.self) { continuation in
            self.continuation = continuation
        }
    }

    func queueWork() {
        Task {
            for i in 1...10 {
                try await Task.sleep(for: .seconds(1))
                continuation.yield(i)
            }

            continuation.finish()
        }
    }
}

makeStream(of:) 메소드를 사용하면 코드가 훨씬 간단해 집니다.

struct NewNumberGenerator {
    let (stream, continuation) = AsyncStream.makeStream(of: Int.self)

    func queueWork() {
        Task {
            for i in 1...10 {
                try await Task.sleep(for: .seconds(1))
                continuation.yield(i)
            }

            continuation.finish()
        }
    }
}

Clock에 sleep(for:) 추가 (Add sleep(for:) to Clock)

SE-0374에서 Swift의 Clock 프로토콜에 새로운 메소드를 추가해서 설정된 초동안 실행을 일시 중지할 수 있지만, 특정 허용 오차를 지원하기 위해서 Task 기반의 잠자기(sleep) 기능을 확장합니다.

Clock의 변경사항은 작지만 중요합니다. 특히 실제 Clock 인스턴스를 목업으로 해서 프로덕션 환경에서 존재할수 있는 테스트 지연을 제거하는 경우에 더욱 그렇습니다. 

예를들어, 이 클래스는 Clock의 모든 종류로 생성될수 있고, 저장 작업을 트리거하기 전에 해당 clock을 사용해서 잠자기(sleep) 할것입니다.

class DataController: ObservableObject {
    var clock: any Clock<Duration>

    init(clock: any Clock<Duration>) {
        self.clock = clock
    }

    func delayedSave() async throws {
        try await clock.sleep(for: .seconds(1))
        print("Saving…")
    }
}

any Clock<Duration>을 사용하기때문에, 프로덕션에서 ContinuousClock와 같은 것을 사용할수 있지만 테스트에서는 
DummyClock을 사용할 수 있으며, 모든 sleep() 명령을 무시해서 테스트를 빨리 실행할 수 있습니다.

이전 버젼의 Swift 에서는 이론적으로 try await clock.sleep(until: clock.now.advanced(by: .seconds(1)))를 사용했지만, 이 예제에서는 Swift가 어떤 종류의 clock인지 몰라서clock.now를 사용할 수 없기 때문에 동작하지 않습니다.

Task 잠자기(sleeping)으로 변경하는 것은 다음 코드를

try await Task.sleep(until: .now + .seconds(1), tolerance: .seconds(0.5))

다음과 같이 변경할 수 있습니다.

try await Task.sleep(for: .seconds(1), tolerance: .seconds(0.5))

작업 그룹 폐기하기 (Discarding task groups)

SE-0381에서 현재 API와 중요한 차이점을 해겷하는 폐기될수 있는 작업 그룹(task group)을 새로 추가하였습니다: 작업 그룹내에서 생성된 작업은 완료되는 즉시 자동으로 폐기되고 제거되며, 이는 작업그룹이 장시간(또는 웹 서버 처럼 영원히) 실행됨에 따라서 메모리 누수가 되지 않습니다.

withTaskGroup() API를 사용할때, 작업 그룹의 하위 작업들에서next()를 호출하거나 반복할때 Swift가 하위 작업과 그 결과 데이터를 버리는 방식으로 인해 한가지 문제가 발생할 수 있습니다. 모든 하위 작업들이 현재 실행중인 경우에 next() 호출하면 코드를 멈추게 되므로 문제가 발생합니다. 항상 연결을 수신하는 서버를 원하므로 연결을 처리할 작업을 추가할 수 있지만, 완료된 이전 작업을 정리하기 위해 자주 중지해야 합니다. 

신규로 폐기하는 작업 그룹을 만드는 withDiscardingTaskGroup()과 withThrowingDiscardingTaskGroup() 함수들을 추가한 Swift 5.9 까지는 명환한 해결책이 없었습니다. 이러한 작업 그룹은 수동으로 사용하는 next()를 호출할 필요없이, 각 작업이 완료되자마다 자동으로 폐기되고 제거됩니다. 

문제를 유발시키는 원인에 대한 아이디어를 제공하기 위해서, 무한히 반복되는 추가되거나 제거된 모든 파일과 디렉토리의 이름을 확인하는 순수한 디렉토리 감시기를 구현할 수 있습니다.

struct FileWatcher {
    // The URL we're watching for file changes.
    let url: URL

    // The set of URLs we've already returned.
    private var handled = Set<URL>()

    init(url: URL) {
        self.url = url
    }

    mutating func next() async throws -> URL? {
        while true {
            // Read the latest contents of our directory, or exit if a problem occurred.
            guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else {
                return nil
            }

            // Figure out which URLs we haven't already handled.
            let unhandled = handled.symmetricDifference(contents)

            if let newURL = unhandled.first {
                // If we already handled this URL then it must be deleted.
                if handled.contains(newURL) {
                    handled.remove(newURL)
                } else {
                    // Otherwise this URL is new, so mark it as handled.
                    handled.insert(newURL)
                    return newURL
                }
            } else {
                // No file difference; sleep for a few seconds then try again.
                try await Task.sleep(for: .microseconds(1000))
            }
        }
    }
}

그리고나서 간단한 앱 내부에서 사용할수 있습니다. 실제 복잡한 처리를 수행하지 않고 URL만 출력합니다.

struct FileProcessor {
    static func main() async throws {
        var watcher = FileWatcher(url: URL(filePath: "/Users/twostraws"))

        try await withThrowingTaskGroup(of: Void.self) { group in
            while let newURL = try await watcher.next() {
                group.addTask {
                    process(newURL)
                }
            }
        }
    }

    static func process(_ url: URL) {
        print("Processing \(url.path())")
    }
}

영원히 실행되거나 적어도 사용자가 프로그램을 종료하거나 우리가 보고 있는 디렉토리를 사용하지 않을때까지 실행됩니다. 하지만, withThrowingTaskGroup()을 사용하는데 한가지 문제가 있습니다: 새로운 하위 작업은 addTask()가 호출될때마다 생성되지만, group.next()를 호출하지 않기 때문에 하위 작업은 결코 제거되지 않습니다. 이 코드는 운영체제의 RAM이 부족해서 프로그램을 강제 종료할때까지 조금씩 - 아마도 수백 바이트 - 더 많은 메모리를 소모하게 될 것입니다.

이 문제는 작업그룹을 폐기하면 완전히 사라집니다: withThrowingTaskGroup(of: Void.self)를 withThrowingDiscardingTaskGroup로 바꾸기만 하면, 각 하위 작업은 작업이 완료되자마자 자동으로 폐기되는 것을 의미합니다.

실제로, 이 문제는 서버 코드에서 주로 발생할 것이며, 서버는 기존의 연결을 원할하게 처리하면서 새로운 연결을 허용할 수 있어야 합니다.

그리고 추가로… (And there’s more…)

SE-0392에서 사용자정의 액터를 실행할 수 있는 기능을 추가해서 개발자가 액터 코드를 실행하는 방법을 세밀하게 제어할 수 있도록 해줍니다. 이는 Swift Evolution 제안서에서도 사용자 정의 실행이 주로 전문가에 의해서 구현되는 것을 기대한다라고 말할 정도로 이 기능은 매우 정확하고 고급 요구사항을 목표로 한 것입니다.

Swift 5.9 이전에 대부분 동시 코드가 실행되는 곳에 관심이 없었습니다 - 이 함수가 스레드 X에서 실행하고 다른 함수는 스레드 Y에서 실행합니다라고 말하지 않지만, Swift가 관리 했습니다. 사용자정의로 실행기(excutors)는 액터 그룹이 동일한 스레드에서 실행되기를 원하거나 운영체제가 특정 작업이 특정 스레드에서 수행되도록 요구할수 있기 때문에 훨씬 더 구체적으로 사용할 수 있게 해줍니다.

 

반응형

'Swift > Tip' 카테고리의 다른 글

What’s new in Swift 5.8  (0) 2023.06.14
Array Extension  (0) 2023.05.08
What’s new in Swift 5.7  (0) 2022.08.08
Type의 문자열 이름 사용하기  (0) 2022.04.01
What’s new in Swift 5.6  (0) 2022.03.21
Swift version과 Xcode version  (0) 2022.02.09
What’s new in Swift 5.5  (0) 2022.01.25
What’s new in Swift 5.4  (0) 2021.04.15
Posted by 까칠코더
,