Lazy
이 번에는 스위프트의 지연변수(lazy variables)와LazySequence
에 대한 글입니다. 저는lazy var
를 “지연변수”라고 번역하고 있습니다. 더 알맞는 용어가 있을지는 좀 더 고민 해보고 여러 사람들의 의견도 들어봐야 하겠는데 “게으른 변수”는 너무 코믹하군요.
문제
채팅 앱에 각 사용자에 대한 아바타(avatar) 기능을 넣는다고 가정해 보겠습니다. 서로 다른 해상도의 아바타를 아래와 같이 표현할 수 있겠습니다.
extension UIImage {
func resizedTo(size: CGSize) -> UIImage {
/* 이미지 크기를 조정하는 연산비용이 많이 들어가는 로직이 여기 있다고 가정하겠습니다 */
}
}
class Avatar {
static let defaultSmallSize = CGSize(width: 64, height: 64)
var smallImage: UIImage
var largeImage: UIImage
init(largeImage: UIImage) {
self.largeImage = largeImage
self.smallImage = largeImage.resizedTo(Avatar.defaultSmallSize)
}
}
이 코드에서의 문제는init
과정에서smallImage
를 계산해 내야 한다는 것입니다.init
안에서Avatar
의 모든 프로퍼티들이 초기화되지 않으면 컴파일 과정에서 에러가 발생되기 때문입니다.
하지만smallImage
의 디폴트 값이 아예 필요 없는 경우에도 연산 비용이 많이 들어가는 이미지의 스케일 변경과정을 불필요하게 매 번 거쳐야합니다.
가용한 해결방법
Objective-C에서는 이런 경우에는 프라이빗 매개 변수를 사용하여 해결하는데 그 내용을 그대로 스위프트로 표현해 보면 다음과 같이 됩니다.
class Avatar {
static let defaultSmallSize = CGSize(width: 64, height: 64)
private var _smallImage: UIImage?
var smallImage: UIImage {
get {
if _smallImage == nil {
_smallImage = largeImage.resizedTo(Avatar.defaultSmallSize)
}
return _smallImage!
}
set {
_smallImage = newValue
}
}
var largeImage: UIImage
init(largeImage: UIImage) {
self.largeImage = largeImage
}
}
이 방법으로 필요한 시점에smallImage
을 생성할 수 있습니다. 처음으로smallImage
프로퍼티에 접근하면nil
을 반환하는 대신에largeImage
로부터smallImage
를 생성하기위한 처리가 실행됩니다.
원하던 것을 구현했습니다. 그러나 코드가 너무 길어졌습니다. 만약 두 개 이상의 해상도에 대한 이미지를 준비해야하는 경우를 상상해 보면 더 많은 코드가 필요해 질 것입니다.
스위프트의 지연 생성자
스위프트에서는 위에서와 같은 복잡한 글루(glue) 코드를 만들지 않고도 지연(lazy) 코드를 통해서 간결하게 처리할 수 있습니다.
class Avatar {
static let defaultSmallSize = CGSize(width: 64, height: 64)
lazy var smallImage: UIImage = self.largeImage.resizedTo(Avatar.defaultSmallSize)
var largeImage: UIImage
init(largeImage: UIImage) {
self.largeImage = largeImage
}
}
lazy
키워드를 사용하는 것 만으로도 훨씬 간결한 코드로 동일한 효과를 낼 수 있습니다.
- 구체적인 값을 미리 지정하지 않고도 지연변수 (
lazy var
)로 지정된
smallImage
에 접근하면 “그 시점”에 디폴트 값이 연산되고 반환됩니다. 그 이후에 다시 이 변수에 접근하면 이미 생성되어 저장되어 있는 값이 반환됩니다. smallImage
를 읽기 전에 명시적으로 어떤 값을 지정하면, 연산 비용이 많이 드는 디폴트 값은 아예 연산을 수행하지도 않습니다.- 아무도
smallImage
프로퍼티에 접근하지 않는다면 이 디폴트 값을 만들어 내는 코드도 아예 수행되지 않습니다.
이 방법으로 디폴트 값을 지정하면서도 불필요한 초기화 과정을 쉽게 회피할 뿐만 아니라 프라이빗 매개 변수를 사용할 필요도 없게 됩니다.
클로저를 활용한 초기화
보통의 프로퍼티와 마찬가지로 지연 변수(lazy vars)에도 클로저를 사용하여 디폴트 값을 지정할 수 있습니다. 이 방법은 디폴트 값을 연산하는데 한 줄로 되지 않고 여러 라인의 코드가 필요한 경우에 유용합니다.
class Avatar {
static let defaultSmallSize = CGSize(width: 64, height: 64)
lazy var smallImage: UIImage = {
let size = CGSize(
width: min(Avatar.defaultSmallSize.width, self.largeImage.size.width),
height: min(Avatar.defaultSmallSize.height, self.largeImage.size.height)
)
return self.largeImage.resizedTo(size)
}()
var largeImage: UIImage
init(largeImage: UIImage) {
self.largeImage = largeImage
}
}
특히 이 경우에 이 변수는 지연(lazy)된 것이므로 클로저 내부에서 “self
를 참조할 수 있습니다”! (클로저를 사용하지 않는 이전 예에서도 동일하게 self를 참조할 수 있습니다.)
지연(lazy)의 뜻은 그 연산 처리를 하는 코드가self
가 완전히 초기화 과정을 끝낸 시점 이후에 호출된다는 것이기 때문에self
를 참조하는 것에 아무런 문제가 없다는 것입니다. 일반 프로퍼티에서는 초기화 과정에 코드가 실행되므로self
를 참조할 수 없습니다.
이 예에서 처럼lazy
변수의 초기화에 사용되는 클로저에는@noescape
가 자동적으로 적용됩니다. 따라서 클로저 내부에서[unowned self]
와 같은 캡쳐 과정이 불필요하며 순환참조가 발생할 걱정이 필요가 없다는 뜻입니다.
참고로 애플의 스위프트 2.1 프로그래맹 언어 레퍼런스의 ARC (Automatic Reference Counting)에 대한 설명을 보면, ARC에서 발생할 수 있는 순환 참조 문제에 대한 설명으로 다음과 같은 예제 코드가 있습니다.
참고: Strong Reference Cycles for Closures링크
class HTMLElement {
let name: String
let text: String?
lazy var asHTML: Void -> String = {
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}
init(name: String, text: String? = nil) {
self.name = name
self.text = text
}
deinit {
print("\(name) is being deinitialized")
}
}
여기서lazy var asHTML
은 그 자체가 클로저인 지연 변수이며, 앞에서 예로 든 클로저를 통해 초기화되는 지연 변수의 경우에는 발생하지 않는 self에 대한 강한 참조가 만들어지면서 순환참조가 발생할 수 있다는 점에 주의해야 합니다.
지연 상수? (lazy let)
위에서 보인 것과 같이 읽기 접근을 할 때에만 연산을 수행하는 변수와 같이 동작을 하는 상수는 허용되지 않습니다. 즉lazy let
은 지원되지 않습니다.lazy
를 구현하기 위해서는 초기화 과정에서는 값이 없는 변수를 만들었다가 나중에 이 변수에 접근하는 시점에 변경하는 방법을 사용해야 하기 때문에 상수로는 불가능합니다.
하지만 상수let
의 흥미로운 특징 중의 한 가지는전역으로로 선언된 상수,타입 프로퍼티(static let
을 사용한 경우)는 자동으로 지연(lazy)처리 된다는 점입니다. (거기에다 쓰레드 세이프하기까지 합니다.)
// 전역 상수. 쓰레드 세이프하게 지연되어 생성됩니다.
let foo: Int = {
print("Global constant initialized")
return 42
}()
class Cat {
static let defaultName: String = {
print("Type constant initialized")
return "Felix"
}()
}
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
print("Hello")
print(foo)
print(Cat.defaultName)
print("Bye")
return true
}
}
이 코드는 “Hello”를 출력하고, 전역 상수 foo를 42로 초기화하며, 그 이후에 타입 상수를 초기화 하여 “Felix”를 출력한 후에 “Bye”를 출력합니다. 상수 foo와 고양이 이름 “Felix”는 이 값에 접근하는 경우에만 초기화 됩니다.
다른 예제: 시퀀스
Array
와 고차함수(high-order function)인map:
을 사용하는 예를 살펴 보겠습니다.
func increment(x: Int) -> Int {
print("Computing next value of \(x)")
return x+1
}
let array = Array(0..<1000)
let incArray = array.map(increment)
print("Result:")
print(incArray[0], incArray[4])
이 코드를 실행해 보면 가장 마지막 줄의incArray
에 접근하기 전에array.map:
을 호출하는 시점에 모든incArray
값이 연산되는 것을 확인할 수 있습니다. 1000개의 “Computing next value of …” 메시지가 출력된 후에 “Result:”가 출력되고 그 이후에incArray[0]
,incArray[4]
의 값이 출력됩니다. 만약increment
함수에서 시간이 많이 걸리는 연산을 수행해야한다면 어떻게 해야할까요?
지연 시퀀스
위의 문제를 또 다른 형태의lazy
를 활용해서 해결해 봅시다.
스위프트의 표준 라이브러리 상에서SequenceType
과CollectionType
프로토콜에는lazy
라는 연산 프로프티(computed property)가 있습니다. 이 프로퍼티는LazySequence
와LazyCollection
을 반환합니다. 이 타입들은map
,flatMap
,filter
와 같은 고차함수에만 적용할 수 있는 전용 타입으로 “지연”된 방식으로 동작합니다.
사용방법은 아래와 같습니다.
let array = Array(0..<1000)
let incArray = array.lazy.map(increment)
print("Result:")
print(incArray[0], incArray[4])
이 코드를 실행해 보면 아래와 같이 접근한 엘리먼트에 대한 연산만 수행된다는 것을 알 수 있습니다!
Result:
Computing next value of 0
Computing next value of 4
1 5
연산 비용이 많이 들어가는 클로저를 사용하여 배열의 요소를 다뤄야할 때 훨씬 효과적으로 처리할 수 있을 것입니다.
연속 지연 시퀀스
아래와 같이 고차 함수(high-order function)들을 연결하여 호출할 때에도lazy
효과를 그대로 유지할 수 있습니다.
func double(x: Int) -> Int {
print("Computing double value of \(x)…")
return 2*x
}
let doubleArray = array.lazy.map(increment).map(double)
print(doubleArray[3])
스위프트를 사용할 때에는 더욱 게을러져야 하겠습니다. Be lazy.