본문 바로가기
Dev/Swift 내 정리

final 키워드를 왜 사용할까?? 정리

by Mintta 2023. 4. 5.

final 키워드를 왜 사용할까?

결론부터

결론부터 말하고 차근차근 하나씩 알아보도록 하겠습니다 !

만약 결론부분만 보시고 이유가 그냥 떠오른다?? 결론만 읽으시고 다아는내용이네.. 하고 뒤로가셔도 됩니다 !

그렇지 않다면 같이 하나씩 살펴볼까요??😄

전반적인 구성과 예시는 WWDC21 Understanding Swift Performance를 참고했습니다.

Class는 default로 메서드들을 dynamic dispatch(뒤에서 다룰 예정! 모른다면 일단 넘어가자)합니다. 이것 자체로는 static dispatch(이것도 뒤에서 !!)와 크게 다르지 않지만, method chaining같은 경우에 inlining(이것도 뒤에서 static dispatch와 같이 설명할 예정!)같은 최적화를 불가능하게 합니다. 따라서 성능에 영향이 생깁니다!

아래 사진의 그래프는 왼쪽으로 갈수록 성능이 좋음을 뜻하고, 오른쪽으로 갈수록 성능이 나쁨을 뜻합니다.

Dynamic을 향해갈수록 성능이 안좋아진다.
static으로 갈수록 성능이 좋다.

모든 클래스들이 dynamic dispatch가 필요한 것은 아닙니다 !

그렇기 때문에 서브 클래싱을 하지 않는 클래스임이 확실하다면 final class로 선언하게 되면 컴파일러는 메서드들을 static dispatch할 것입니다 ! 따라서 성능이 개선되는 효과를 얻을 수 있습니다 !

즉, 서브클래싱을 하지 않겠다면 class앞에 final을 붙여주면 됩니다.

여기까지만 일단 대충 생각해보면 static dispatch는 성능이 좋고, dynamic dispatch는 성능이 나쁘다는 걸 어느정도 알 수 있겠네요.

 

 

그렇다면 왜 그럴까요?

 

시작

이유를 찾기위해 이제부터 본격적으로 위에서 넘어간 여러 개념들을 하나씩 짚으면서 살펴봅시다 !

Static dispatch

Method dispatch는 메서드가 호출될 때 실제로 실행될 메서드를 결정하는 과정을 말합니다.

만약 이 과정이 컴파일 타임에 이루어진다면, static dispatch라고 합니다.

static dispatch를 하게 되면 컴파일러가 어느 시점에 어떤 것을 실행시켜야할지 미리 모든 것을 알고 있기 때문에, 코드를 inlining을 통해 최적화 할 수 있습니다.

inlining이라는 키워드가 또 나왔습니다. 이제는 inlining이 어떤건지 살펴봅시다 !

Inlining

struct Point {
    var x, y: Double
    
    func draw() {
       code // 실제 구현 코드 !
    }
}

func drawAPoint(_ param: Point) {
    param.draw()
}

let point = Point(x: 0, y: 0)
drawAPoint(point)

위와 같은 코드가 있다고 해봅시다 !

코드를 보면 x, y좌표를 가진 Point가 있고 drawAPoint 메서드를 통해서 draw 메서드를 호출하고 있습니다. drawAPoint 메서드와 Point의 draw메서드는 static하게 dispatch됩니다.

static dispatch를 하게 되면 컴파일러가 어느 시점에 어떤 것을 실행시켜야할지 미리 모든 것을 알고 있다고 위에서 말했었죠? 그 말은 즉, 컴파일러가 전체 코드에 대한 insight를 가지고 있다는 뜻입니다 !

즉! 우리가 지금 이 코드를 보고 있듯이 코드가 한줄씩 위에서부터 일일히 하나씩 읽어내려가는 것이 아닌 전체 코드에 대한 시야를 이미 가지고 있는 거죠 !

이제 컴파일러가 위 코드를 최적화하는 과정을 코드로 살펴볼까요?

 

struct Point {
    var x, y: Double
    
    func draw() {
       code // 실제 구현 코드 !
    }
}

func drawAPoint(_ param: Point) {
    param.draw()
}

let point = Point(x: 0, y: 0)
drawAPoint(point)

초기상태

struct Point {
    var x, y: Double
    
    func draw() {
        // 실제 구현 코드 !
    }
}

func drawAPoint(_ param: Point) {
    param.draw()
}

let point = Point(x: 0, y: 0)
point.draw()

1단계

  • drawAPoint(point) → point.draw()
struct Point {
    var x, y: Double
    
    func draw() {
        // 실제 구현 코드 !
    }
}

func drawAPoint(_ param: Point) {
    param.draw()
}

let point = Point(x: 0, y: 0)
// 실제 구현 코드 !

2단계

  • point.draw() → 실제 구현 코드
💡 정리해보자면
1. 컴파일러는 drawAPoint(point) 을 point.draw()로 교체함.
2. point.draw()를 draw메서드의 실제 구현 코드로 교체

처음에는 drawAPoint메서드를 호출을 했었는데, 지금은 코드 한줄로 대체된 것을 볼 수 있었습니다 !

 

함수를 호출하는 작업이 아예 없어졌기 때문에 function call stack을 쌓았다가 다시 꺼내는 등의 call stack overhead 하나 없이 모두 최적화 되어 생략되었습니다.

 

이렇게 메서드를 메서드의 body로 대체하는 최적화 기법을 inlining이라고 합니다

💡 static dispatch는 컴파일러가 코드 전체에 대한 insight를 가지고 있어 그를 기반으로한 inlining등의 최적화 기법을 사용해 성능을 높인다고 이해할 수 있겠네요 !

 

static dispatch가 성능이 좋다고 한 이유에 대해서 어느정도 감이 오셨나요??

 

그렇다면 이제 dynamic dispatch에 대해서 알아볼게요..!

(화이팅 이것만하면 이제 곧입니다…!!)

Dynamic dispatch

dynamic dispatch.. 이름만 봐도 static dispatch랑 그냥 반대로 생각하면 될 것 같지 않나요???ㅋㅋ

 

Dynamic dispatch는 static dispatch와 달리 컴파일 타임에 실제로 실행될 메서드를 결정할 수가 없습니다. 그래서 런타임에 직접 실제로 보고! 해당 메서드를 결정하게 됩니다.

따라서 전체 코드에 대한 insight또한 있을 수가 없겠죠?? 그래서 위에서 설명한 inlining같은 최적화 기법은 자연스럽게 사용하지 못합니다 !

 

여기까지만 보면 뭐 다 안좋은 것 같은데 dynamic dispatch는 도대체 왜 있는거야?? 라고~ 생각하실 것 같아요.

이제부터 얘는 대체 왜 존재하는지에 대해 알아보겠습니다 !

Why dynamic dispatch?

💡 바로~ 결론부터 말하고 들어가보자면 **polymorphism(다형성)**과 같은 것을 위해서 존재합니다 !

예제와 함께 살펴봅시다 !

class Drawable { func draw() {} }

class Point: Drawable {
    var x, y: Double
    override func draw() {
        print("Point")
    }
}

class Line: Drawable {
    var x1, y1, x2, y2: Double
    override func draw() {
        print("Line")
    }
}

var drawables: [Drawable]
for d in drawables {
    d.draw()
}

다항성을 설명할 때 대표격으로 다루는 예제입니다.

코드를 살펴보면 Drawable타입의 배열을 돌면서 draw() 메서드를 호출하면 각각의 인스턴스에 맞는 draw메서드가 호출됩니다.

 

이것만 봐도 컴파일러가 어째서 컴파일 타임에 결정하지 못하는지에 대해서 조금은 감이 오지 않으시나요❓

d.draw()에서 d는 Point가 될수도, Line이 될수도 있기 때문에 이걸 컴파일 타임에 알 수가 없겠죠??

그래서 위에서 말했듯이 런타임에 직접 실제로 보고! 해당 메서드를 결정하게 되는 것입니다 !

 

 

그렇다면 어떤 메서드를 호출해야 하는지 어떻게 결정하는 걸까요??

 

How dynamic dispatch?

컴파일러가 어떤 메서드를 호출해야하는지 메서드를 찾는 과정을 같이 볼게요 !

  1. 컴파일러는 해당 클래스의 Type에 대한 포인터 field를 클래스에 추가합니다. (화살표).

2. 우리가 메서드를 호출할 때! virtual method table(vtable)이라고 불리는 테이블을 조회합니다. 해당 테이블에는 실행시킬 함수의 구현체에 대한 포인터가 있습니다 !

Virtual Method Table(가상 함수 테이블)?
Swift는 가상 함수 테이블을 통해서 가상 메소드(virtual method)를 관리한다.

가상 메소드란, 자식 클래스에서 부모 클래스의 메소드를 재정의(ovverride)하여 사용하는 것을 말한다.
이 때 부모 클래스의 메소드가 호출되지 않고, 자식 클래스에서 재정의한 메소드가 호출된다.
이러한 동작을 구현하기 위해 Swift는 가상 메소드 테이블을 사용한다.
이러한 방식으로 Swift는 다형성을 지원하며, 객체 지향 프로그래밍의 장점 중 하나인 유연성과 확장성을 제공한다.

가상 메소드 테이블은 객체의 클래스마다 하나씩 생성된다. 각각의 가상 메소드 테이블은 클래스에 정의된 가상 메소드의 포인터들을 저장하고 있다. 객체가 생성될 때, 해당 객체의 클래스의 가상 메소드 테이블을 참조하며, 가상 메소드를 호출할 때에는 해당 클래스의 가상 메소드 테이블에서 메소드의 위치를 찾아서 실행한다.

이러한 방식으로 Swift는 다형성을 지원하며, 객체 지향 프로그래밍의 장점 중 하나인 유연성과 확장성을 제공한다.
(챗GPT 진짜 대박입니다 여러분..)

d.draw()를 컴파일러가 하는 일에 대해서 바꾸어보면..

d.type.vtable에서 실행시킬 정확한 draw 메서드를 찾고 있는 것을 볼 수 있습니다 !

그리고 draw메서드에 implicit,즉 묵시적으로 self 파라미터에 ‘draw를 실행시키는 해당 클래스의 인스턴스’를 인자를 넘깁니다.

Point라면 Point인스턴스, Line이라면 Line인스턴스를 넘기게 되겠죠.

💡 정리해보자면
1. 컴파일러가 해당 클래스에 Type에 대한 포인터를 담는 필드를 클래스에 추가한다.
2. 우리가 메서드를 호출할 때에! 컴파일러는 static memory에 저장된 클래스의 타입 정보안에 있는 vTable을 조회해서 실행시킬 올바른 구현체를 찾는다. (포인터)
3. 해당 메서드에 묵시적으로 해당 클래스의 인스턴스를 넘겨서 해당 클래스의 메서드를 호출한다.

지금까지 Dynamic dispatch이 왜 존재하는지, dynamic dispatch가 메서드를 런타임에 결정하는 과정에 대해서 살펴보았습니다. static과 비교해서 최적화도 없고 과정이 정말 많죠??

그렇기 때문에 우리는 필요한 때에만 dynamic dispatch를 사용해야할 필요가 있습니다 !

dynamism을 필요한 곳외에도 남발하게 되면 성능에 영향을 끼치겠죠?


Wrap up

다시 그럼 초기의 질문으로 돌아와서

final 키워드를 왜 사용할까? 성능적 이점?

final을 왜 사용할까?

  • 클래스는 default로 dynamic dispatch를 통해 메서드를 dispatch하는데 이를 static dispatch로 바꾸어 컴파일 시간에 실행시킬 메서드를 결정시켜 inlining같은 최적화 기법을 통해 성능을 향상시키기 위해 (vTable X)
    • final을 사용하면 상속이 되지 않는 최종 class가 되는데, 이것은 vTable은 사용하지 않고 static dispatch를 하기 위해서 나오는 당연한 결과.

피드백 대환영입니다 !

 

Ref


Understanding Swift Performance - WWDC16 - Videos - Apple Developer

댓글