본문 바로가기

Swift

[Swift] defer의 동작 원리는 무엇일까? (번역)

  Swift2에 도입된 defer는 실제로 많이 사용되진 않습니다. 그래서 보통 defer가 어떻게 동작하고 있는지 궁금해하지 않습니다. defer는 현재 scope에서 제일 끝에서 실행되어야하는 코드를 포함하는 클로저와 함께 사용되는 연산자입니다. 함수에 return문이 많고 각 return문 앞에 동일한 코드를 복사하여 붙여 넣어야 하는 경우 유용하게 사용됩니다. 또는 스레드로부터 안전한 동작을 구현하기 위해 로직을 실행하기 전에 NSLock 객체를 lock() 하였다면,  defer { lock.unlock() }  을 함께 넣으면 교착 상태에 빠지거나 메모리 누수가 발생하는 걸 방지할 수 있습니다.

 

  함수의 끝에서 실행된다는 것은 알고 있지만 정확히 어떻게 동작되는 것인지 자세히 살펴보도록 하겠습니다.

 

# 예시 1

var a = "Hello"

func b() -> String {
    defer { a.append(" world") }
    return a
}

 

  매우 단순한 예시입니다. 이 경우에 defer문 외의 유일한 다른 라인은  return a  이므로 다음과 같이 단순화 할 수 있다고 생각할 수 있습니다.

 

func d() -> String {
    a.append(" world")
    return a
}

 

  b()와 d()는 완벽히 같은 함수일까요? print를 찍어서 결과값이 같은지 확인해보겠습니다.

 

a = "Hello"
print(b()) // Hello
a = "Hello"
print(d()) // Hello world

 

  차이점이 보이시나요? defer문 안에 코드를 넣은 경우엔 함수의 값이 반환된 이후에 실행되는 것처럼 보입니다. 출력된 결과만 놓고보면 이상한 점이 있습니다. 우선, defer문의 정의에 모순됩니다.

 

  정의에 따르면 defer문은 defer문이 포함된 scope을 벗어나기 전에 실행되어야하기 때문입니다. 그러나 더 중요한 것은 함수가 반환된 후 어떤 작업을 수행하는 것이 기술적으로 불가능하다는 것입니다. 사실상 모든 프로그래밍 언어의 모든 함수는 궁극적으로 return문으로 끝이납니다. return문이 실행되면 현재 scope을 벗어나 모든 로컬 리소스를 해제하고 스택에서 함수를 꺼낸 후 호출 계층의 한 단계에서 중단된 위치로 이동합니다.

 

  함수에서 리소스들을 반환한 후에도 해당 리소스를 사용할 수 있도록 허용한 Objective-C의 autorelease가 있기 때문에 가능하다고 생각할 수 있어 혼란스러울 수 있습니다. 하지만 이건 완전히 다른 이야기입니다. 이 동작 방식에는 현재 scope내에서 autorelease된 것으로 표시된 각 객체에 release 메시지를 보내는 Run Loop의 모든 iteration에서 drain되는 autorelease pool이 있다는 것입니다. 하지만 defer는 이러한 방식을 사용하지 않습니다.

 

  실제로 어떻게 동작하는지 알아보기 위해 Hopper Disassembler를 이용하여 알아보겠습니다. 다음 명령어를 통해 코드를 컴파일 합니다.

 

 xcrun swiftc your_source_code.swift -o output_file 

 

  Hopper를 다운로드하고 ouput_file을 로드합니다. Hopper는 전체 앱을 실행 가능한 프로세서 명령 리스트로 나열합니다. 또한 가독성을 위해 C와 유사한 수도코드를 생성합니다. 이것이 실제로 무슨 일이 일어나는지 분석하는데 필요한 수도코드 입니다. b 함수의 disassemble된 수도코드는 다음과 같습니다.

 

int _$S05test_A01bSSyF() {
    swift_beginAccess(_$S05test_A01aSSvp, &var_18, 0x20, 0x0);
    rcx = *_$S05test_A01aSSvp; // 3
    swift_bridgeObjectRetain(rcx);
    swift_endAccess(&var_18);
    $defer #1 (); // 6
    rax = rcx; // 7
    return rax;
}

int _$S05test_A01bSSyF6$deferL_yyF() {
    var_40 = Swift.String_builtinStringLiteralutf8CodeUnitCountisASCII.init(" world", 0x6, 0x1);
    swift_beginAccess(_$S05test_A01aSSvp, &var_20, 0x21, 0x0, &var_20, 0x21);
    $SSS6appendyySSF(var_40, 0x1);
    swift_endAccess(&var_20);
    rax = swift_bridgeObjectRelease(var_40);
    return rax;
}

 

  먼저 첫번째 함수는 b()를 나타내고 두번째 함수는 defer에 전달하는 클로저입니다. return문은 예상대로 두 함수의 마지막 작업입니다. swift_beginAccess 및 swift_endAccess에 대한 호출은 전역 변수 a에 액세스한 결과입니다. 거기에서 전달된 랜덤 변수에 신경쓸 필요는 없습니다.

 

  중요한 라인은 3, 6, 7번째 입니다. 3번째 라인에서 a의 초기 값은 rcx 레지스터에 저장됩니다. 그런 다음 defer에 전달하는 모든 내용은 6번째 라인에서 호출됩니다. 이 경우에, defer는 새로운 문자열 " world"를 생성하고 이를 문자열 a와 연결합니다. defer가 무언가를 rax에 할당하고 반환한다는 사실은 무시해도 됩니다. 반환될 값이 레지스터에 저장되는 것은 단순한 컨벤션일 뿐입니다. 이제 첫 번째 함수로 다시 돌아가겠습니다. defer를 완료하자마자 이전에 저장된 값이 7번째 라인에서 rcx에서 rax로 다시 이동되고 다음 단계에 반환됩니다.

 

  간단히 말해, defer를 호출하고 새로운 값을 적용하기 전에 원래 값을 저장하는 것이 원인이였습니다. 따라서 b 함수에서 defer를 제외한다면 아래처럼 a 변수를 수정하기 전에 임시 변수에 a를 저장하는 것까지 포함되는 것입니다.

 

func c() -> String {
    let d = a
    a.append(" world")
    return d
}

 

  하나의 추가 변수를 도입하여 약간의 복잡성이 추가된 것처럼 보이므로 메모리 오버헤드가 생기는건 아닌지 궁금할 수 있습니다. 이 문제에 대해 더 자세히 알아보려면 먼저 변수 할당 작업이 어떻게 동작하는지를 알아보아야 합니다.

 

  Swift에는 두 가지 유형의 데이터가 있습니다. 값(value)으로 전달되는 데이터(struct, primitives)와 참조(reference)로 전달되는 데이터(class)입니다. 참조 유형 객체를 사용하여 이 작업을 수행하려고하면 동일한 방식으로 동작하지 않습니다. 어떻게하든 함수 내에서 수정하면 최신 수정 내용이 반환됩니다.

 

  그러나 String은 값(value) 타입이므로 각 할당이 새 복사본을 생성합니다. 하지만 이 내용이 그렇게 간단하진 않습니다. 문자열 자체는 텍스트가 아무리 길더라도 16바이트 짜리 크기의 wrapper일 뿐입니다. 그러므로 실제 텍스트를 포함하는 기본 문자 버퍼는 참조(reference)유형이며 할당하는 작업동안 메모리를 절약하기 위해 copy-on-write 를 유지합니다. copy-on-write에 대한 자세한 내용은 WWDC 영상을 참고해주시기 바랍니다. 따라서 a 값을 읽는 문자열 변수들을 얼마나 선언했는지와 상관없이 실제 복사는 a.append(" world")가 호출될 때 한 번만 발생합니다.

 

  따라서 이론적으로는 copy-on-write 방식덕분에 추가적인 String 변수로 인한 부담은 없습니다. 그러나 disassemble된 defer의 내용을 b() 안에 그대로 옮겨놓고보면 b()와 c()의 내부가 거의 동일하다는 것을 알 수 있습니다.

 

// defer문까지 하나로 합쳐진 b 함수
int _$S05test_A01bSSyF() {
    swift_beginAccess(_$S05test_A01aSSvp, &var_18, 0x20, 0x0);
    rcx = *_$S05test_A01aSSvp;
    swift_bridgeObjectRetain(rcx);
    swift_endAccess(&var_18);
    var_40 = Swift.String_builtinStringLiteralutf8CodeUnitCountisASCII.init(" world", 0x6, 0x1);
    swift_beginAccess(_$S05test_A01aSSvp, &var_20, 0x21, 0x0, &var_20, 0x21);
    $SSS6appendyySSF(var_40, 0x1);
    swift_endAccess(&var_20);
    swift_bridgeObjectRelease(var_40);
    rax = rcx;
    return rax;
}

 

// disassemble된 c 함수
int _$S05test_A01cSSyF() {
    swift_beginAccess(_$S05test_A01aSSvp, &var_20, 0x20, 0x0, &var_20);
    rax = *_$S05test_A01aSSvp;
    swift_bridgeObjectRetain(rax);
    swift_endAccess(&var_20);
    var_80 = Swift.String_builtinStringLiteralutf8CodeUnitCountisASCII.init(" world", 0x6, 0x1);
    swift_beginAccess(_$S05test_A01aSSvp, &var_38, 0x21, 0x0);
    $SSS6appendyySSF(var_80, 0x1);
    swift_endAccess(&var_38);
    swift_bridgeObjectRelease(var_80);
    rax = rax;
    return rax;
}

 

# 예시 2

이번엔 다른 코드를 실행해 보겠습니다.

 

var a: String? = nil

func b() -> String {
    a = "Hello world"
    defer { a = nil }
    return a!
}

print(b()) // Hello world

 

  이 코드는 더 재밌습니다. 어떤 면에서는 현재 scope을 벗어나기 전에 캡쳐된 리소스를 폐기하는 defer의 일반적인 사용 방식과 유사합니다. 이 예시는 함수 시작 부분에 값이 할당된 후 끝에서 nil로 바뀌는 글로벌 Optional<String> 변수에 대한 코드입니다.

 

  이 함수는 "Hello world"를 성공적으로 프린트합니다. 뭔가 말이 되는 것 같기도 합니다. 하지만 위에서 defer문은 함수 scope내에서 실행된다는 것을 이미 확인했습니다. 그렇다면 force unwrapping시에 왜 crash가 나지 않는 것일까요? Hopper를 통해 이 함수가 어떻게 동작하는지 알아보도록 하겠습니다.

 

int _$S10test_force1bSSyF() {
    var_40 = Swift.String_builtinStringLiteralutf8CodeUnitCountisASCII.init("Hello world", 0xb, 0x1); // 2
    swift_beginAccess(_$S10test_force1aSSSgvp, &var_18, 0x21, 0x0, 0x21);
    rdi = *_$S10test_force1aSSSgvp;
    rsi = *qword_100001078;
    *_$S10test_force1aSSSgvp = var_40;
    *qword_100001078 = 0x1;
    _$SSSSgWOe(rdi, rsi);
    swift_endAccess(&var_18); // 9
    swift_beginAccess(_$S10test_force1aSSSgvp, &var_30, 0x20, 0x0); // 10
    rax = *_$S10test_force1aSSSgvp;
    rcx = *qword_100001078;
    var_48 = rax;
    var_50 = rcx;
    _$SSSSgWOy(rax, rcx);
    swift_endAccess(&var_30); // 16
    if (var_48 != 0x0) { // 17
        var_58 = var_48; // 18
        var_60 = var_50;
        $defer #1 (); // 20
        rax = var_58; // 21
    }
    else {
        stack[-168] = "test_force.swift"; // 24
        *(int32_t *)(&stack[-168] + 0x20) = 0x1;
        *(&stack[-168] + 0x18) = 0x6;
        *(int32_t *)(&stack[-168] + 0x10) = 0x2;
        *(&stack[-168] + 0x8) = 0x10;
        Swift_fatalErrorMessage first-element-marker  first-element-marker fileline.flags("Fatal error", 0xb, 0x2, "Unexpectedly found nil while unwrapping an Optional value", 0x39, 0x2);
        asm { ud2 };
        rax = loc_100000d90(); // 31
    } // 32
    return rax; // 33
}

 

  간단해보였던 3줄짜리 코드가 34줄의 매우 복잡해보이는 코드로 바뀌었습니다. 모든 라인의 문자와 숫자에 신경쓸 필요는 없습니다. 알고리즘을 분석하기 위해 의미있는 chunk들로 그룹화를 해보겠습니다. 예를 들어 2~9번째 라인은 "Hello world" 문자열을 변수 a에 할당하는 원래 b 함수의 첫번째 라인을 나타냅니다. defer문은 20번째 줄에 있습니다. 즉, 나머지 부분은 겉보기에 사소해 보이지만 return a! 와 연관이 있음을 알 수 있습니다.

 

  사실, return a! 는 보기만큼 간단하지 않습니다. 실제로는 3가지 작업이 진행됩니다.

 

1. a값을 읽고 로컬 scope에 저장합니다. 이것은 10번째 라인부터 16번째 라인에 있는 swift_beginAccess / swift_endAccess의 두번째 쌍 사이에서 발생합니다.

 

2. 이전에 받은 값을 unwrapping 합니다. 17번째 라인부터 32번째까지는 if문이 사용됩니다. 18~21번째 라인은 값이 존재할 때 성공적인 결과를 나타내며, 24~31번째 라인은 nil을 전달할 때 발생하는 crash와 관련이 있습니다.

 

3. 33번째 라인에서 드디어 값을 반환합니다.

 

  위 수도코드에서 색칠된 부분은 아래의 b함수의 색칠된 부분과 매칭이 됩니다.

  Disassemble된 코드에서 defer의 위치를 주목해서 보아야 합니다. 이제 앱이 crash 나지 않는 이유가 분명해졌습니다. 왜냐하면 a 값을 마지막으로 읽는 작업 (파랑) 후에 a = nil (빨강) 이 실행되기 때문에 앱이 crash 나지 않는 것이 분명합니다. 하지만 그 사이에 더 흥미로운 점이 있습니다. defer 문이 force unwrapping 로직의 이전이나 이후가 아니라 로직의 중간에 나타나고 있다는 것입니다. defer 코드가 다른 함수의 구현부에 삽입되었다는 점은 매우 흥미롭습니다. 함수가 끝나기 직전에 defer를 항상 호출하기는 하지만 그럼에도 불구하고 예상치 못한 곳에 깊이 위치하고 있기 때문에 인상적입니다.

 

결론

  매우 단순한 연산자인 defer는 자기 자신을 함수 끝으로 위치시키는 매우 좋은 역할을 하고 있습니다. defer의 진정한 힘은 기존의 Swift return 문을 각각 개별 작업으로 분리시키고 마지막 반환 프로세서 명령(일명 ret)이 실행되기 직전에 그 사이에 끼어들 수 있다는 것입니다. 이렇게하면 중간에 임시 변수를 추가할 필요없이 보다 우아하고 자연스러운 코드를 작성할 수 있습니다.

 

원글

How "defer" operator in Swift actually works - Sergey Smagleev

태그