Swift 컴파일러는 클래스의 상속과 관련하여 이런 저런 여러가지 제약 사항들을 가지고 있다. 이는 클래스의 상속 관계는 자칫 매우 복잡한 문제로 발전할 수 있으며, 사람은 이 문제와 관련해서 너무나 관대하여 자신이 실수했다는 사실을 알아차리기 힘들다는 경험적 사실 때문이다. 그래서 “이렇게 하면 되겠지”하는 코드들이 유독 클래스 상속과 관련해서는 뜻대로 되지 않는 경우가 많다.
이 글에서는 상속의 관점에서 프로퍼티는 어떤 제약을 받는지, 헷갈리는 몇 가지 사례들과 함께 점검해보도록 하자.
프로퍼티의 개념
객체 지향 프로그래밍 개념 중에 ‘캡슐화’라는 것이 있다. 어떤 객체가 그 자신이 표현하는 개념과 연관된 값을 가지려면, 그 내부에 이러한 값들을 저장할 수 있는 메모리 공간을 보유하고 있어야 한다. 하지만 기본적으로 객체는 외부의 임의적인 접근에서부터 스스로를 지켜야 한다. 따라서 Swift는 메모리 보호를 위해 객체 외부에서 객체 내부의 저장 공간에 함부로 액세스하는 것을 차단한다.
하지만 객체가 내부에 저장한 값은 특정한 목적하에서는 객체 외부에서 사용할 수 있어야 한다. 따라서 객체는 객체 외부에서 내부의 값을 액세스할 수 있는 접근자를 제공하게 된다. 이러한 약간의 간접적(?)인 접근을 통해 프로그래머는 객체 내부의 상태는 물론 객체가 그 속성값을 외부에 제공하는 방식 또한 완전하게 컨트롤 할 수 있게 된다.
결국 어떤 객체가 그 속성을 갖고 있으며, 외부에서 해당 속성값을 사용하려면 세 가지 요소가 필요하다는 결론에 다다르게 되는데, 그 세가지는 다음과 같다.
- 내부에 보관될 모종의 값을 위한 저장 공간 (backing storage라고도 한다.)
- 해당 값을 읽기 위한 인터페이스 (getter 접근자, 줄여서 getter라고 한다.)
- 해당 값을 변경하기 위한 인터페이스 (setter 접근자, 줄여서 setter라고 한다.)
이 개념은 Objective-C에서도 그대로 따르고 있다. 관심있는 사람은 이 블로그에 소개한 Objective-C의 프로퍼티 관련 글을 좀 더 읽어 보도록 하자.
Swift 역시 이 설계를 그대로 따르고 있다. 다만 그 문법 자체가 일반적인 멤버 정의 처럼 보이기 때문에 getter와 setter를 생각하지 못할 뿐이다. 따라서 항상 getter와 setter를 중심으로 프로퍼티를 생각하면 혼동의 소지가 적다. 아 그런데 왜 저장 공간은 그렇게 중요하지 않을까?
저장 프로퍼티와 계산 프로퍼티
먼저 다음 간단한 예를 하나 살펴보자.
class Person {
var firstName: String
let lastName: String
init(firstName:String, lastName:String) {
self.firstName = firstName
self.lastName = lastName
}
}
이 클래스 정의를 통해서 어떤 점들을 알 수 있을까?
firstName
속성은 String 타입이며, 인스턴스 생성 후에도 변경이 가능하다.lastName
속성도 String 타입이다.let
으로 선언되었기 때문에 인스턴스 생성 후에는 변경이 불가능하다.- 이 두 속성(property)에 할당되는 값은 실제로 객체 내부에 저장된다.
이 것이 통상적인 ‘저장 프로퍼티'(stored property)의 개념이다. 우리는 문법적으로 멤버 변수처럼 작성했지만 실제로 컴파일러는 이 정보를 보고 값이 저장될 스토리지 공간과 getter 혹은 getter와 setter를 자동으로 생성해준다. (이것은 Objective-C 컴파일러가 클래스 프로퍼티를 위한 저장공간과 접근자 메소드를 자동으로 synthesize하는 것과 같은 패턴이다. 물론 Objective-C 컴파일러의 초창기에는 @property라는 컴파일러 지시어가 없었고, 클래스의 저장 공간을 위한 backing storage 변수와 getter/setter 메소드를 직접 작성했다. )
만약, firstName
과 lastName
을 연결한 fullName
을 얻고자 한다면 이걸 메소드로 작성할 수도 있을 것이다.
func fullName() -> String {
return "\(self.firstName) \(self.lastName)"
}
그런데 이 “fullName”은 별도의 입력도 없고 굳이 메소드의 모양이어야 할 필요가 없기도 하다. 마치 하나의 프로퍼티처럼 사용되어도 될 것 같다.
var fullName: String {
return "\(self.firstName) \(self.lastName)"
}
이렇게 선언한 것이 바로 ‘계산 프로퍼티’이다. 계산 프로퍼티는 별도의 저장공간 없이 다른 프로퍼티의 값을 참조하는 간접적 프로퍼티이다. 이는 일정한 관계를 갖는 프로퍼티들을 연결하는데 요긴하게 사용될 수 있다.
조금 다른 예를 살펴보자. 이번 예는 조금 복잡한데, 계산 프로퍼티이면서 getter와 setter를 모두 갖는 경우이다. 직사각형은 원점과 그 크기로 정의할 수 있는데, 이 두 값으로부터 ‘중심점’을 계산할 수 있다. 또한 중심점을 변경하면, 그 크기가 고정되어 있는 것으로 정했을 때 원점의 새 좌표를 얻을 수 있는 셈이다.
struct Point {
var x = 0.0, y = 0.0
}
struct Size {
var width = 0.0, height = 0.0
}
struct Rect {
var origin: Point()
var size: Size()
var center: Point {
get {
let centerX = origin.x + size.width / 2
let centerY = origin.y + size.height / 2
return Point(x:centerX, y:centerY)
}
set(newCenter) {
origin.x = newCenter.x - size.width / 2
origin.y = newCenter.y - size.height / 2
}
}
}
이 예제가 시사하는 바는 사실 강력하다. 우리가 앞서 정의했던 프로퍼티의 세 가지 요건 중에서 실제로 ‘저장공간’은 중요한 것이 아니라는 것이다. 저장 프로퍼티와 계산 프로퍼티는 이러한 구현 방식을 구분하기 위해서 소개되는 개념인데, 실제로 모든 프로퍼티는 계산 프로퍼티이다. 그리고 그 중 저장 프로퍼티라는 특별한 유형이 있는 것이다. (추가적인 저장공간을 필요로하며, 그 값을 그대로 읽거나 쓰기만하면 되는)
프로퍼티는 어떻게 상속되는가
부모 클래스의 프로퍼티가 저장 프로퍼티이든 계산 프로퍼티이든 상관없이 var
로 선언되어 있다면 이것은 자식 클래스에서 메소드와 마찬가지로 오버라이드 할 수 있다. 프로퍼티 = getter + setter 이기 때문에 이 둘을 한꺼번에 오버라이드 해주면 되는 것이다.
class Member: Person {
override fullName {
return "\(self.firstName.uppercased())-\(self.lastName.uppercased())"
}
}
기본적으로 모든 클래스의 프로퍼티는 자신을 상속하는 자식 클래스로 고스란히 상속된다. 그래서 오버라이드를 생각하지 않는다면 아무런 문제가 되지는 않는다. 또 오버라이드를 생각하더라도 문제될 것이 없다. 왜? 앞서 설명한 구조를 이해한다면 어렵지 않기 때문이다. 프로퍼티의 상속과 오버라이드에 대한 일반적인 규칙은 다음과 같다.
- 대전제이자, 당연한 사실: 어떤 클래스를 상속해서 하위 클래스를 만드는 것이, 기존 부모 클래스의 내부 구조에 영향을 줄 수는 없다.
- 주목할 사실: Swift 컴파일러는 결국 프로퍼티는 getter와 setter의 조합이다.
- 1에 의해
let
으로 선언한 프로퍼티는 하위 클래스에서var
로 선언 형식을 변경할 수 없다. - 저장 프로퍼티를 오버라이드하는 경우, 반드시 접근자에 내에서 부모의 동일 프로퍼티를 액세스해야 한다.
- 읽고 쓰기가 가능한 프로퍼티를 읽기 전용으로 오버라이드할 수 없다.
보다 쉽게 해석하면 다음과 같이 정리할 수 있다. “var
로 선언했다면, 저장 프로퍼티도 오버라이드할 수 있다. 단 이 때, getter/setter를 모두 작성해야하며, 부모의 프로퍼티를 반드시 액세스해야 한다. 해당 값을 참조할 때 앞뒤로 값에 변형을 가하는 역할로 프로퍼티 오버라이드를 제한할 것을 권장한다” 뭔가 제약 자체가 사용 목적을 제한하려는 것 같아서 굳이 왜? 이런 생각이 들기도 하지만, Swift는 C가 아닌 만큼 이러한 제약으로 괴랄한 사용을 최대한 막으려고 하는 의도로 보인다.
그외에 참고할 사항에는 다음과 같은 것이 있다.
- (아까 나온 이야기) 저장/계산 특성에 상관없이 부모 클래스가 setter를 정의하고 있다면, getter를 오버라이드할 때 반드시 setter도 같이 오버라이드해야 한다.
- 애초에 setter만 갖는 프로퍼티를 작성하는 것은 불가능하다. Swift는 이 규칙을 강제하여 여러분이 변태가 되는 것을 방지한다.
- 1과는 반대로 읽기 전용 프로퍼티라도 하위 클래스에서는 읽기-쓰기 프로퍼티로 오버라이드할 수 있다. 그리고 해당 클래스의 모든 자식은 1에 의해서 항상 읽고 쓰는 프로퍼티를 갖게 된다.
그런데, 옵저버는?
Swift 프로퍼티의 get/set 영역을 작성하는 예제들을 보면 추가적으로 willSet
, didSet
블럭을 작성하여 프로퍼티 변경 전/후의 동작을 감지하는 기능이 있는 것을 볼 수 있는데, 이들을 옵저버라고 한다. 옵저버의 상속과 오버라이드 관련 규칙은 좀 특이하다. 결론부터 말하면 프로퍼티의 옵저버는 상속되지 않으며, 오버라이드 할 수도 없다.
왜냐하면 접근자의 구현 내에서는 자식 클래스의 접근자와 부모 클래스의 접근자가 서로 구별되기 때문이다. 따라서 옵저버 메소드들은 매 레벨마다 별도로 작성된다. 오버라이드된 프로퍼티에 대한 옵저버 동작은 다음과 같은 단계를 거친다.
- 자식 클래스의 프로퍼티를 변경하기 직전에 자식 프로퍼티의
willSet
이 호출된다. - 자식 프로퍼티를 변경하면서
set
이 호출된다. - 자식 프로퍼티는 setter 내에서 부모의 프로퍼티를 변경할 것이다.
- 부모의
willSet
이 또 호출된다. - 부모의 setter 내에서 값이 변경된다.
- 부모의 setter가 동작을 끝냈다면 부모의
didSet
이 호출된다. - 자식의 setter가 동작을 끝냈다면 자식의
didSet
이 호출된다.
정리
지금까지 Swift의 클래스의 상속과 관련하여 프로퍼티의 오버라이드는 어떤식으로 이루어지는지 살펴보았다. 관련된 규칙이 점점 많아지면서 골치가 아플 수 있는데, 이 글에서 제시한 중심원리인 ‘프로퍼티는 사실 getter와 setter를 합쳐놓은 것’이라는 점을 중심으로 생각해보면 관련된 정책들을 이해하는데 도움이 될 것이다.