우리는 종종 final 키워드가 붙은 class를 보기도하고 사용하기도 한다. 해당 키워드를 사용하면 다른 클래스가 해당 클래스를 상속하지 못하게 막는 기능을 한다. 얼핏 듣기론 성능이 더 좋아진다는 말도 들었던 것 같다. 진짜일까?
apple 블로그의 한 포스트에 관련 내용이 있어 공부 및 번역을 해봤다. final 키워드를 사용하면 왜 성능이 더 좋아지는지 알 수 있고, 추가적으로 다른 방법들도 확인할 수 있다.
Method Dispatch에 대해 간략히 설명하자면 다음과 같다.
Dispatch
프로그램이 동작하면서 어떤 메소드를 호출할 것인가를 결정하는 메카니즘이다. Dispatch 방식은 Static Dispatch, Dynamic Dispatch로 두가지가 있다. (물론, 더 세분화할 수 있다. Swift에서는 Dynamic Dispatch의 종류로 V-table Dispatch와 Message Dispatch가 있다.)
Static Dispatch
Swift에서 가장 간단하고 빠른 메서드 디스패치 방법이다. 여기서는 컴파일러가 컴파일 시점에 객체의 정확한 타입을 알고 최적화된 코드를 생성해준다. 런타임에서의 필요하지 않기 때문에 더 빠른 성능을 제공한다.
Dynamic Dispatch
컴파일러가 런타임에 메소드 호출을 처리하는 방법이다. 런타임에서 객체의 타입을 확인하고 해당 객체가 호출될 메소드를 찾기 때문에 코드의 유연성과 재사용성을 개선할 수 있다.
다른 언어들과 마찬가지로 Swift는 클래스가 슈퍼클래스에 선언된 메서드와 프로퍼티를 오버라이드할 수 있도록 허용한다. 이는 프로그램이 런타임에서 참조된 메서드나 프로퍼티를 결정하고 indirect call 또는 indirect access를 수행해야 한다는 것을 의미한다. 이 기술을 dynamic dispatch라고 하며, 각각의 간접적인 사용마다 일정한 런타임 오버헤드가 발생하므로 언어 표현력을 높이는 대신 성능에 상수적인 부담을 준다. 성능이 중요한 코드에서는 이러한 오버헤드는 바람직하지 않다. 이 블로그 포스트에서는 "final", "private", 그리고 Whole Module Optimization이라는 세 가지 방법을 사용하여 이러한 동적성을 제거하여 성능을 향상시키는 세 가지 방법을 소개한다.
Consider the following example:
class ParticleModel {
var point = ( 0.0, 0.0 )
var velocity = 100.0
func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
point = newPoint
velocity = newVelocity
}
func update(newP: (Double, Double), newV: Double) {
updatePoint(newP, newVelocity: newV)
}
}
var p = ParticleModel()
for i in stride(from: 0.0, through: 360, by: 1.0) {
p.update((i * sin(i), i), newV:i*1000)
}
현재 코드 상으로는, 컴파일러는 다음과 같이 동적 디스패치된 호출을 발생시킨다:
p의 update 호출p의 updatePoint 호출p의 point 튜플 속성 가져오기p의 velocity 속성 가져오기이 코드를 보면 dynamic calls가 이렇게까지 발생할 것이라고 예상치 않았을 것이다. 동적 호출은 ParticleModel의 하위 클래스가 point나 velocity를 계산된 속성으로 재정의하거나 updatePoint()나 update()를 새 구현으로 재정의할 수 있기 때문에 필요하다. (-> 재정의 할 수 있는 것은 다 dynamic call 하는듯?)
Swift에서 dynamic dispatch call은 메소드 테이블에서 함수를 찾아 간접 호출을 수행하여 구현된다. 이는 direct call보다 느리기 때문에 성능에 영향을 줄 수 있다. 또한, indirect call은 많은 컴파일러 최적화를 방해하기 때문에 더욱 비싸지게 된다. 성능 중요도가 높은 코드에서는 이러한 동적 동작이 필요하지 않을 때 이를 제한하는 기술을 사용하여 성능을 향상시킬 수 있다.
final 키워드는 선언이 오버라이드 될 수 없음을 나타내는 클래스, 메소드, 또는 속성에 대한 제한이다. 이를 사용하면 컴파일러가 동적 디스패치 간접 호출을 안전하게 생략할 수 있습니다. 예를 들어, 다음 코드에서 point와 velocity는 객체의 저장된 속성에서 직접 액세스되며, updatePoint()는 직접 함수 호출을 통해 호출됩니다. 반면, update()는 여전히 동적 디스패치를 통해 호출되므로 하위 클래스가 사용자 지정 기능으로 update()를 오버라이드할 수 있습니다.
class ParticleModel {
final var point = ( x: 0.0, y: 0.0 )
final var velocity = 100.0
final func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
point = newPoint
velocity = newVelocity
}
func update(newP: (Double, Double), newV: Double) {
updatePoint(newP, newVelocity: newV)
}
}
클래스 자체에 속성을 부착하여 전체 클래스를 final로 지정할 수 있다. 이를 통해 클래스를 서브클래싱하는 것을 금지하고, 클래스의 모든 함수와 속성이 마찬가지로 final임을 의미한다.
final class ParticleModel {
var point = ( x: 0.0, y: 0.0 )
var velocity = 100.0
// ...
}
private 키워드를 선언에 적용하면 해당 선언의 가시성을 현재 파일로 제한할 수 있다. 이를 통해 컴파일러는 모든 잠재적인 오버라이드 선언을 찾을 수 있다. 이러한 오버라이드 선언이 없으면 컴파일러는 자동으로 final 키워드를 추론하고 메소드와 속성 액세스에 대한 간접 호출을 제거할 수 있다.
현재 파일에서 ParticleModel을 오버라이드하는 클래스가 없다고 가정하면, 컴파일러는 모든 동적 디스패치 호출을 private 선언에 대한 직접 호출로 대체할 수 있다.
class ParticleModel {
private var point = ( x: 0.0, y: 0.0 )
private var velocity = 100.0
private func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
point = newPoint
velocity = newVelocity
}
func update(newP: (Double, Double), newV: Double) {
updatePoint(newP, newVelocity: newV)
}
}
이전 예제와 마찬가지로 point와 velocity는 직접 접근되며 updatePoint()는 직접 호출된다. 다시 한번, update()는 private이 아니므로 간접적으로 호출된다.
final과 마찬가지로 private 속성을 클래스 선언에 적용하여 클래스와 해당 클래스의 모든 속성과 메서드를 private으로 만들 수 있다.
private class ParticleModel {
var point = ( x: 0.0, y: 0.0 )
var velocity = 100.0
// ...
}
기본값으로 설정되는 internal 접근 제한자는 해당 선언이 선언된 모듈 내에서만 볼 수 있다. Swift는 보통 모듈을 이루는 파일들을 별도로 컴파일하기 때문에, 컴파일러는 internal 선언이 다른 파일에서 오버라이드되었는지 여부를 알 수 없다. 하지만 Whole Module Optimization(전체 모듈 최적화)가 활성화되면, 모듈 전체가 동시에 컴파일된다. 이는 컴파일러가 전체 모듈을 함께 고려하고, internal 선언 중에 오버라이드가 없으면 final로 추론할 수 있게 만든다.
원래의 코드 스니펫으로 돌아가서, 이번에는 ParticleModel에 몇 가지 추가적인 public 키워드를 추가해 보자.
public class ParticleModel {
var point = ( x: 0.0, y: 0.0 )
var velocity = 100.0
func updatePoint(newPoint: (Double, Double), newVelocity: Double) {
point = newPoint
velocity = newVelocity
}
public func update(newP: (Double, Double), newV: Double) {
updatePoint(newP, newVelocity: newV)
}
}
var p = ParticleModel()
for i in stride(from: 0.0, through: times, by: 1.0) {
p.update((i * sin(i), i), newV:i*1000)
}
Whole Module Optimization을 사용하여 이 코드 조각을 컴파일 할 때 컴파일러는 property point, velocity 및 메소드 호출 updatePoint()에서 final을 추론 할 수 있다. 반면, update()가 public access를 가지므로 update()가 final인지 추론 할 수 없다.
Dynamic Dispatch를 사용하면 코드의 유연성과 재사용성을 높일 수 있지만, 이를 남용하면 성능 문제가 발생할 수 있다. 성능이 중요한 코드에서는 Dynamic Dispatch를 줄여서 성능을 높일 수 있다.
final과 private 키워드를 활용해 dynamic dispatch를 줄임으로써 성능을 향상시킬 수 있다. Whole Module Optimization(전체 모듈 최적화)를 활용해, internal 선언 중에 오버라이드가 없다면 final로 추론한다.
public class의 경우 오버라이드와 서브클래싱이 불가능하고, Whole Module Optimization이 활성화되어 모듈 전체가 동시에 컴파일되어 final로 추론할 수 있다.
Dispatch에 대해 궁금하다면 아래 링크들을 더 공부해보는 것도 좋을 것 같다.