알아두면 쓸모 있는 Swift의 기능들
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
문도 넣을 수 있습니다.
- github: awesome-function-builders 에 쿨한 function builder 들을 모아놓은 깃헙 레포도 존재합니다.
결론
- Swift의 기능들을 잘 조합하면 다양한 상황에서 도움이 될 수 있다.