Property Wrappers

Hoontopia
hoontopia
Published in
9 min readNov 11, 2019

또 늦었다. Swift 5.1 이 나온지가 언제인데 지금 Property Wrappers 를 보고있다. (그래도 해를 안넘긴게 어디인가…) 시작해보자.

Swift-Evolution (아래 링크) 에 정의된 내용을 정리해보았다.

소개

반복적으로 나타나는 Property 구현 패턴에 대해, 고정된 패턴 셋을 컴파일러에 하드코딩 (lazy 및 @NSCopying 등) 을 하는 대신에, 이러한 패턴을 라이브러리로 정의 할 수 있도록 Property Wrapper 매커니즘을 제공한다.

동기

lazy 및 @NSCopying 등과 같이 Properties 에 적용 할 패턴들을 수용하려고 시도했으나, 이는 좁은 범위에서만 유용하다. 예를 들면, Swift 는 기본 언어 기능으로 lazy Properties 를 제공한다. 이는 종종 Property 가 Optional 로 노출되는 것을 피하기 위해 필요한데, 만약 이를 지원하지 않았다면 이와 동일한 효과를 얻기 위해 다음과 같은 보일러플레이트(boilerplate) 코드를 계속 작성해주어야한다.

struct Foo {
// lazy var foo = 1738
private var _foo: Int?
var foo: Int {
get {
if let value = _foo { return value }
let initialValue = 1738
_foo = initialValue
return initialValue
}
set {
_foo = newValue
}
}
}

다만, 몇 가지 단점이 있는데, 이는 컴파일러를 더 복잡하고 덜 직교하게(?) 만든다. 또한 융통성이 없다. 컴파일러에 하드코딩 그만하고 싶다!!

또한, 암시적으로 언래핑된 Optional 은 Non-Optional let 에 비해서 안전하지 못하므로, 한 번 할당 한 후에 불변 Property 로 만드는 것이 종종 합리적이다.

class Foo {
let immediatelyInitialized = "foo"
var _initializedLater: String?

// initializedLater 를 non-optional 'let' 으로 만들고 싶다!
// 한 번만 할당될 수 있고, 그 전엔 액세스 할 수 없다!
var initializedLater: String {
get { return _initializedLater! }
set {
assert(_initializedLater == nil)
_initializedLater = newValue
}
}
}

지금은 위와 같이 작성한다… 그래서 해결책을 제시했다!

해결책

Property 선언이 그것을 구현하는 데에 사용된 Wrapper 를 명시 할 수 있는 Property Wrapper 를 도입한다. Wrapper 는 Attribute 를 통해 설명된다.

@Lazy var foo = 1738

Lazy 를 Property Wrapper 방식으로 구현하여 적용한 것이다.

@propertyWrapper
enum Lazy<Value> {
case uninitialized(() -> Value)
case initialized(Value)

init(wrappedValue: @autoclosure @escaping () -> Value) {
self = .uninitialized(wrappedValue)
}

var wrappedValue: Value {
mutating get {
switch self {
case .uninitialized(let initializer):
let value = initializer()
self = .initialized(value)
return value
case .initialized(let value):
return value
}
}
set {
self = .initialized(newValue)
}
}
}

Property Wrapper 타입은 Wrapper 로 사용하는 Property 에 Storage 를 제공한다. wrappedValue Property 는 Wrapper 의 실제 구현을 제공하는 반면, init(wrappedValue:)은 Property 의 값으로부터 Storage 를 초기화 할 수 있다.

다음과 같은 Property 의 선언은,

@Lazy var foo = 1738

아래처럼 해석된다.

private var _foo: Lazy<Int> = Lazy<Int>(wrappedValue: 1738)
var foo: Int {
get { return _foo.wrappedValue }
set { _foo.wrappedValue = newValue }
}

다음과 같이 초기화도 가능하다.

extension Lazy {
init(body: @escaping () -> Value) {
self = .uninitialized(body)
}
}

func createAString() -> String { ... }
@Lazy(body: createAString) var bar: String

Property Wrapper 를 통해 자체적으로 API 의 관계를 설명 할 수도 있다. 다음은 name 을 통해 Database 의 Field 를 참조하는 Property Wrapper 의 예이다.

@propertyWrapper
public struct Field<Value: DatabaseValue> {
public let name: String
private var record: DatabaseRecord?
private var cachedValue: Value?

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

public func configure(record: DatabaseRecord) {
self.record = record
}

public var wrappedValue: Value {
mutating get {
if cachedValue == nil { fetch() }
return cachedValue!
}

set {
cachedValue = newValue
}
}

public func flush() {
if let value = cachedValue {
record!.flush(fieldName: name, value)
}
}

public mutating func fetch() {
cachedValue = record!.fetch(fieldName: name, type: Value.self)
}
}

다음과 같이 Field Property Wrapper 를 사용 가능하다.

public struct Person: DatabaseModel {
@Field(name: "first_name") public var firstName: String
@Field(name: "last_name") public var lastName: String
@Field(name: "date_of_birth") public var birthdate: Date
}

기존 값을 Flush 하고 Database 에서 새 값을 가져오고 싶을 수 있다.

그러나, 모델의 각 Property 의 밑줄있는 변수 (_firstName, _lastName 및 _birthdate) 는 Private 이므로 직접 접근 할 수 없다.

따라서 원하는 API (Wrapper 에서 정의한 메서드라고 이해하면 될 듯) 를 제공하기 위해, Property Wrapper 는 선택적으로 Projection 을 제공한다. WrapperprojectedValue Property 를 정의하면 Projection 이 제공된다.

@propertyWrapper
public struct Field<Value: DatabaseValue> {
// ... API as before ...

public var projectedValue: Self {
get { self }
set { self = newValue }
}
}

Projection Property 앞에는 $가 붙는다. (예를 들면, firstName Property 의 Projection 의 경우 $firstName)

Projection Property 를 정의한 경우 다음의 선언은

@Field(name: "first_name") public var firstName: String

다음과 같다.

private var _firstName: Field<String> = Field(name: "first_name")

public var firstName: String {
get { _firstName.wrappedValue }
set { _firstName.wrappedValue = newValue }
}

public var $firstName: Field<String> {
get { _firstName.projectedValue }
set { _firstName.projectedValue = newValue }
}

결과적으로 이렇게 쓸 수 있다.

somePerson.firstName = "Taylor"
$somePerson.flush()

예제

대표적인 예제는 User Defaults 에 적용한 예제이다.

@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T

var wrappedValue: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
enum GlobalSettings {
@UserDefault(key: "FOO_FEATURE_ENABLED", defaultValue: false)
static var isFooFeatureEnabled: Bool

@UserDefault(key: "BAR_FEATURE_ENABLED", defaultValue: false)
static var isBarFeatureEnabled: Bool
}

이처럼 Property Wrapper 를 이용하면, Boilerplate 코드를 줄일 수 있을 것 같다.

--

--