Swift는 C와 Objective-C와의 연동은 뛰어나지만 JS, Python 과 같은 dynamic 언어들과는 상대적으로 떨어집니다. 그 이유들로 추가되고 있는 기능들이 있습니다. KeyPath, dynamicMemberLookup 등 Swift의 dynamic 한 기능들을 살펴보고 언제 사용할 수 있을지 고찰해 보려고 합니다. 추가로 property wrapper 와 result builder 를 살펴봅니다.

KeyPath - 키패θ

이름이 멋집니다. KeyPath는 값에 대한 참조가 아닌 프로퍼티에 대한 참조입니다. 말그대로 어떤 타입의 값에 대한 path 를 표현하는 것입니다. 변수는 값을 저장합니다. 즉 값에 대한 참조입니다. 변수를 사용하면 값(value)에 접근할 수 있습니다. 이런거겠죠.

struct Employee {
    var name: String
}

let jay = Employee(name: "Jay")
jay.name // "Jay"

KeyPath 는 변수와는 다르게 프로퍼티 자체를 가지고 있는 오브젝트 입니다. 프로퍼티를 참조합니다.

struct Employee {
    let name: String
    let role: String
    let level: Int
}

var designer = Employee(name: "Song", role: "Designer", level: 10)
// KeyPath<Employee, String>
let nameKeyPath = \Employee.name

KeyPath 표현식은 백슬래시 \ 로 시작합니다. \<type name>.<path>.

<type name> 은 제네릭 파라미터를 가지고 있거나 subscript 타입도 가능합니다. <path> 는 프로퍼티 이름이나 subscript 가 됩니다. 타입 추론이 되기 때문에 컴파일러가 현재 컨텍스트에서 타입을 추론할 수 있다면 <type name> 을 생략하고 \.<path> 로 사용할 수 있습니다.

5 가지의 KeyPath 가 존재합니다.

  • AnyKeyPath
  • APartialKeyPath
  • KeyPath<Source, Target>
  • WritableKeyPath<Source, Target>
  • ReferenceWritableKeyPath<Source, Target>
    • Source 가 reference type 일 때 사용합니다.
let strKeyPath: KeyPath<String, Int> = \String.count

let subscriptKeyPath: KeyPath<[Int], Int> = \[Int].count

let arrKeyPath: KeyPath<Array<Int>, Int> = \.count

// WritableKeyPath<Dictionary<String, Int>, Dictionary<String, Int>.Values>
let dictKeyPath = \Dictionary<String, Int>.values

let secondDomainKeyPath = \[String].[1]

KeyPath 를 이용해 값에 접근하기 위해서는 subscript(keyPath:) 에 KeyPath 를 넘겨주면 됩니다.


struct Episode {
    let id: Int
    let name: String
}

var e1 = Episode(id: 42, name: "Scary Being")
e1[keyPath: \Episode.id] // 42 

Javascript 의 오브젝트를 연상시킵니다. 단 Swift 는 type safe 하죠.

// javascript
function Episode() {
    this.name = "not scary being"
    this.id = 41
}

let e1 = new Episode()
e1["name"] // "not scary being"

Typescript 에는 keyof 오퍼레이터가 존재하는데요. 자세히 다룰 수는 없지만 Swift의 KeyPath와 유사합니다.

// typescript
interface Rectangle {
  width: number;
  height: number;
}
type RectangleProperties = keyof Rectangle;
let rect: Rectangle = { width: 50, height: 50 };
const propertyName: RectangleProperties = "height";
const height = rect[propertyName];

Swift5.2 에서는 Key Path Expressions 을 함수로 사용할 수 있도록 하는 Proposal이 구현되었습니다. (Root) -> Value의 함수를 \Root.value 로 사용할 수 있습니다. SE-0249

let episodes = [
    Episode(id: 1, name: "Mon Story"),
    Episode(id: 2, name: "Tue Story"),
    Episode(id: 3, name: "Wed Story")
]
episodes.map(\.name) // episodes.map { $0[keyPath: \.name] }
  • Swift.Collection 에 구현되어 있는 sorted 함수의 closure type 은 (Root) -> Value 가 아닙니다. KeyPath 를 사용해 구현하면 아래와 같습니다.
// func sorted(by areInIncreasingOrder: (Self.Element, Self.Element) throws -> Bool) rethrows -> [Self.Element]

extension Sequence {
    func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] {
        // areInIncreasingOrder
        sorted { $0[keyPath: keyPath] < $1[keyPath: keyPath] }
    }
}

// closure 사용
episodes.sorted(by: { $0.name < $1.name } )

// keypath 사용
episodes.sorted(by: \.name)
  • KeyPath 는 appending 가능합니다. KeyPath<A, B> .appending( KeyPath<B, C>) 처럼 append 하는 KeyPath 의 root type 은 B 여야 합니다.
struct Episode {
    let id: Int
    let name: String
    let creator: Creator
}

struct Creator: Decodable {
    let id: Int
    let name: String
}

var e1 = Episode(id: 42, name: "Scary Being", creator: .init(id: 72, name: "Why"))

// KeyPath<Episode, Creator>
let episodeCreatorKeyPath = \Episode.creator

// KeyPath<Episode, Creator> + KeyPath<Creator, String> = KeyPath<Episode, String>
let creatorNameKeyPath = episodeCreatorKeyPath.appending(path: \Creator.name)

e1[keyPath: episodeCreatorKeyPath.appending(path: \.id)] // 72
e1[keyPath: creatorNameKeyPath] // Why
  • KVO 에서도 KeyPath를 사용할 수 있습니다. webview 의 progress 를 observe 할 때 아래처럼 사용합니다.
var progressObservation: NSKeyValueObservation?

progressObservation = webView
    .observe(\.estimatedProgress, options: [.new, .old]) { (webview, change: NSKeyValueObservedChange<Double>) in
        print(webview.estimatedProgress)
    }

dynamicMemberLookup

KeyPath 는 dynamicMemberLookup과 함께 사용할 수 있습니다. dynamicMemberLookup 은 python 과 같은 dynamic 한 언어들과의 연동을 위해 추가된 기능입니다. 하지만 Swift 의 type safety 를 버리지 않습니다. dot 으로 프로퍼티에 접근하지만 그 접근되는 프러퍼티는 런타임에 결정됩니다. class, structure, enumeration 과 protocol 에 추가 할 수 있습니다. @dynamicMemberLookup 를 추가하고 subscript(dynamicMemberLookup:) 를 구현하면 됩니다.

subscript(dynamicMemberLookup:) 에 넘길 수 있는 파라미터 타입은 KeyPath, WritableKeyPath, ReferenceWritableKeyPath 그리고 ExpressibleByStringLiteral 타입 입니다.

@dynamicMemberLookup
struct Episode {
    let id: Int
    var name: String

    var arbitrary: [String: String] {
        ["aliases": "\(name)", "emojis": "🌱 🥵 🍁 ❄️"]
    }

    subscript(dynamicMember member: String) -> [String] {
        get { arbitrary[member, default: ""].components(separatedBy: " ") }
        set { name = "\(name) \(newValue.joined())" }
    }
}

var episode = Episode(id: 42, name: "그때, 우리가 있었던 계절")
episode.aliases // ["그때,", "우리가", "있었던", "계절"]
episode.emojis // ["🌱", "🥵", "🍁", "❄️"]
episode.emoji = ["🥕"]
episode.name // "그때, 우리가 있었던 계절 🥕"

dynamicMemberLookup 과 KeyPath 를 이용한 Builder pattern

  • 책 Effective Java - 아이템 2: “생성자에 매개변수가 많다면 빌더를 고려하라.”

타입의 매개변수가 많을 때 생성자 대신 빌더를 사용하면 사용이 쉽고 가독성 좋은 코드를 얻을 수 있다는 내용입니다.

public final class NutritionFacts {
  private final int servingSize;
  private final int servings;
  private final int calories;
  private final int fat;
  private final int sodium;
  private final int carbohydrate;

  public static final class Builder {
    ...
    public Builder calories(int val) {
      calories = val;
      return this;
    }
    ...
  }
}

...

NutritionFacts cocoCola = new NutritionFacts.Builder(240, 8).
      calories(100).sodium(35).carbohydrate(27).build();

Java 의 클래스에 대한 내용이지만 Swift 의 클래스 생성시에 같은 장점을 얻을 수 있습니다. Base 를 Root 로 하는 KeyPath 를 이용해 dynamicMemberLookup subscript 함수를 구현합니다. Buildable protocol 을 선언하고 NSObject 가 Buildable 를 사용할 수 있도록 확장합니다. 아래의 코드 입니다.

@dynamicMemberLookup
public struct Builder<Base: AnyObject> {

    private var base: Base

    public init(_ base: Base) {
        self.base = base
    }

    public subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<Base, Value>) -> (Value) -> Builder<Base> {
        { [base] value in
            base[keyPath: keyPath] = value
            return Builder(base)
        }
    }

    public func set<Value>(_ keyPath: ReferenceWritableKeyPath<Base, Value>, to value: Value) -> Builder<Base> {
        base[keyPath: keyPath] = value
        return Builder(base)
    }

    public func build() -> Base { base }
}

public protocol Buildable {
    associatedtype Base: AnyObject
    var builder: Builder<Base> { get }
}

public extension Buildable where Self: AnyObject {
    var builder: Builder<Self> { Builder(self) }
}

extension NSObject: Buildable {}
  • 별도로 생성자를 만들지 않아도 NSObject 타입에서 builder를 사용할 수 있습니다.
var profileImage = UIImageView(image: UIImage(named: "profile")).builder
    .translatesAutoresizingMaskIntoConstraints(false)
    .set(.layer.cornerRadius, value: 10)
    .backgroundColor(UIColor.lightGray)
    .clipsToBounds(true)
    .contentMode(.scaleAspectFill)
    .build()

var nameLabel = UILabel().builder
    .text("픽셀")
    .textAlignment(.center)
    .textColor(UIColor.black)
    .numberOfLines(1)
    .build()
  • UIKit 의 imageView 를 생성할 때 가능한 방법 중 하나는 아래와 같습니다. builder 사용한 코드가 더 좋은 가독성을 가진다고 생각되면 builder 를, 아니면 아래처럼 사용해도 무방할 것 같네요.
var thumb: UIImageView = {
    $0.translatesAutoresizingMaskIntoConstraints = false
    $0.clipsToBounds = true
    $0.backgroundColor = UIColor.lightGray
    $0.contentMode = .scaleAspectFill
    return $0
}(UIImageView(image: UIImage(named: "thumb")))
  • dynamicMemberLookup 과 비슷하게 dynamicCallable SE-0216도 존재합니다. 이 역시 dynamic한 언어들과 브짓지를 위해 사용할 수 있습니다.

Property Wrapper

Property는 상태를 가질 수 있습니다. 상태가 변할 때 변경사항을 전파하는 로직은 매우 일반적이죠. Property Observer didSet, willSet 을 사용해 다른 타입으로 값을 바꿔 사용하거나 UI업데이트 작업을 trigger 할 수 있겠네요. Property Wrapper 는 property 에 대해 저장하는 로직의 코드와 정의하는 코드를 분리합니다. 어떤 Property 의 값이 변경될 때 UserDefaults에 저장한다고 하면 유사한 property 들을 didSet 에서 매번 구현해 줘야 하죠. property wrapper 는 프러퍼티에 대한 wrapper 코드를 정의해 관련 작업을 추상화 할 수 있습니다.

  • container 와 wrapper 의 차이를 아시나요? 일반적으로 container 는 포함하는 element가 여러개 일 때, wrapper 는 한개 일때 쓴다고 합니다.

structure, enumeration, 또는 class 에 @propertyWrapper attribute 를 추가하고 wrappedValue 를 선언 해 주면 됩니다. 추가로 projectedValue 를 선언하면 wrappedValue 에 대한 project value (투영 값) 기능을 사용할 수 있습니다. property wrapper 로 구현된 property 의 앞에 $ 기호를 추가해 접근할 수 있습니다.

@propertyWrapper
public struct ReactionCount {

    private var count: Int

    public var wrappedValue: Int {
        get { count }
        set { count = max(newValue, 0) }
    }

    public var projectedValue: String {
        formattedWithSeparator(count)
    }

    public init(_ reactionCount: Int) {
        self.wrappedValue = reactionCount
    }

    private func formattedWithSeparator(_ value: Int, _ separator: String = ",") -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.groupingSeparator = separator
        return formatter.string(for: value)!
    }
}

...

@ReactionCount(0)
var likeCount: Int

likeCount = 31415
$likeCount // "31,415" projectedValue
  • property wrapper 를 사용하면 UserDefaults 를 좀 더 쉽게 사용할 수 있습니다.
@propertyWrapper
public struct UserDefaultsWrapper<Value> {
    let key: String
    let defaultValue: Value
    var storage: UserDefaults

    public init(storage: UserDefaults = .standard, key: String, default value: Value) {
        self.storage = storage
        self.key = key
        self.defaultValue = value
    }

    public var wrappedValue: Value {
        get { storage.value(forKey: key) as? Value ?? defaultValue }
        set { storage.setValue(newValue, forKey: key) }
    }
}

...

@UserDefaultsWrapper(key: "isToolTipDismissed", default: false)
static var isTooltipDismissed: Bool

property wrapper 는 SwiftUI 에서 많이 사용되고 있습니다. @Environment, @State, @Binding ...

result builder (Swift 5.4)

SE-0289 Swift5.4 버전에 result builder가 구현되었습니다. Swift5.1 에서 function builder 로 구현되었지만 공식적이지 않은 기능이었죠. 네이밍을 변경하고 새로운 기능들이 추가되면서 Swift5.4 버전에 당당하게 구현되었습니다. result builder 를 사용하면 읽기 쉽고 사용이 쉬운 코드를 작성할 수 있습니다. SwiftUI 에서 뷰를 만들때 사용되는 기능입니다. 예를 들면 여러개의 NSAttributedString 을 조합할 때 아래 처럼 사용할 수 있습니다.

@_functionBuilder // @resultBuilder for Swift5.4
public class NSAttributedStringBuilder {
    public static func buildBlock(_ components: NSAttributedString...) -> NSAttributedString {
        let att = NSMutableAttributedString(string: "")
        return components.reduce(into: att) { $0.append($1) }
    }
}

public extension NSAttributedString {
    class func composing(@NSAttributedStringBuilder _ parts: () -> NSAttributedString) -> NSAttributedString {
        parts()
    }
}

...

let resultAttString: NSAttributedString = NSAttributedString.composing {
    NSAttributedString(attachment: NSTextAttachment(image: UIImage(named: "newBadge")!))
    NSAttributedString(string: episodeName)
}

static 메서드로 buildBlock 을 구현합니다. NSAttributedString.composing 클로져 내부 NSAttributedString 뒤에 컴마가 없죠. Swift5.4의 result builder 는 if문 이나 for문도 넣을 수 있습니다.

결론

  • Swift의 기능들을 잘 조합하면 다양한 상황에서 도움이 될 수 있다.

참고