우리는 종종 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에 대해 궁금하다면 아래 링크들을 더 공부해보는 것도 좋을 것 같다.