프로퍼티 상속 파고들기 – Swift

Swift의 클래스 상속과 관련하여 이니셜라이저의 동작 규칙은 안정성이라는 측면을 아주 중요하게 여기고 있어서 몇 가지 규칙이 강제되고 있다. 이 규칙은 컴파일러에 의해서 강제되고 있기 때문에 이니셜라이저를 작성할 때 주의를 기울여야 하고, 그렇지 않으면 언뜻 보기에 아무런 문제가 없는 것 같은 코드가 컴파일이 되지 않는 경우가 많다.

이니셜라이저와 더불어서 프로퍼티 역시 작성할 때 주의해야 할 부분이 몇 가지 있다. 문제는 이니셜라이저와 마찬가지로 클래스를 상속하는 상황에서 프로퍼티 상속과 관련되는 케이스가 많아지고 헷갈리는 상황을 제법 접할 수 있게 된다는 것이다. 이번 글에서는 프로퍼티의 상속에 관한 여러 사례들을 검토해보도록 하자.

프로퍼티의 구성

먼저 짚고 넘어가야 할 것은 Swift에서 클래스의 프로퍼티는 C구조체의 멤버와는 다르다는 것이다. C의 구조체는 각 멤버의 사이즈와 메모리정렬에 따라 구조체의 크기가 결정되고, 또 이 정보를 바탕으로 저장공간 내의 값을 직접 액세스하는 개념이다.

하지만 Swift의 프로퍼티는 Objective-C의 그것과 동일한 기본개념으로부터 출발한다. 먼저 메모리 어딘가에 선언된 각각의 프로퍼티에 대한 내부 저장소(backing storage)가 존재한다. 이 내부저장소에 대한 외부로부터의 직접적인 액세스는 차단된다. 대신 각 프로퍼티에 대한 읽기/쓰기 접근에 대한 접근자가 별도로 제공된다.

예를 들어 Objective-C에서 Person이라는 클래스를 작성할 때, 이 사람의 firstName을 위한 프로퍼티는 다음 세 가지 요소를 통해서 구현된다.

  1. firstName 값을 저장할 내부 저장 변수
  2. 읽기용 접근자
  3. 쓰기용 접근자

따라서 클래스 선언에 필요한 코드는 다음과 같다.

@interface Person: NSObject
NSString *_firstName;
- (NSString*) firstName;
- (void) setFirstName:(NSString*)name
@end

@implementation Person

- (NSString*)firstName {
  return _firstName;
}

- (void)setFirstName:(NSString*)name {
  [_firstName release];
  _firstName = [name copy];
}

물론 최근의 코드에서는 이런식으로 모든 접근자와 스토리지 변수를 선언하지는 않는다. 컴파일러가 필요한 코드를 자동으로 작성해줄 수 있기 때문에 다음과 같은 식으로 작성한다.

@interface Person: NSObject
@property (copy, nonatomic) NSString* firstName;
@end

@implementation Person
@synthesize firstName;
@end

Swift의 프로퍼티 역시 동일한 구성을 가지고 있다. Objective-C와 차이가 있다면 willSet, didSet의 옵저버메소드까지 설정할 수 있다는 점이다.

class Person {
  let firstName: String
  let lastName: String

  init(firstName: String, lastName: String) {
    self.firstName = firstName
    self.lastName = lastName
  }
}

코드 상에서 간단히 선언한 두 개의 프로퍼티는 C의 구조체 멤버 선언과 거의 같은 모양이지만, 컴파일러가 내부에서 하는 일은 완전히 다르다. Swift 컴파일러는 두 개의 문자열 타입 프로퍼티를 저장할 내부 저장공간을 할당하고 각각에 대한 읽기/쓰기 접근자를 내부적으로 만든다.

프로퍼티의 구분

기본적으로 이런 형태로 내부에 저장공간을 할당하는 프로퍼티를 저장 프로퍼티(stored property)라 부른다. 반대로 별도의 저장 공간을 사용할 필요가 없는 프로퍼티가 있는데, 이를 계산 프로퍼티(computed property)라 한다. Objective-C에서는 모든 접근자는 기본적으로 메소드이기 때문에 Person의 fullName을 얻는 메소드를 다음과 같이 작성했다.

- (NSString*) fullName {
  return [NSString StringWithFormat:@"%@ %@", self.firstName, self.lastName];
  // self.firstName은 [self firstName]의 축약이다.
}

Swift에서도 fullName을 계산 프로퍼티로 다음과 같이 작성할 수 있다.

class Person {
  let firstName: String
  let lastName: String
  var fullName: String {
    return "\(firstName) \(lastName)"
  }
}

Swift 컴파일러는 fullName이 계산 프로퍼티라는 점을 알게되기 때문에 추가적인 문자열 타입의 저장공간을 할당하지 않으며, person.fullName 과 같이 접근이 이루어지는 시점에 작성된 블럭을 실행하고 그 결과를 리턴하게 된다.

하지만 이렇게 프로퍼티를 구분하는 것은 내부의 스토리지 변수를 할당하느냐의 여부일 뿐이며, 프로퍼티 상속과 오버라이드에서는 오로지 gettet/setter 에 대해서만 초점을 두고 생각하면 된다.

프로퍼티는 어떻게 상속되는가

기본적으로 모든 클래스의 프로퍼티는 자신을 상속하는 자식 클래스로 고스란히 상속된다. 그래서 오버라이드를 생각하지 않는다면 아무런 문제가 되지는 않는다. 또 오버라이드를 생각하더라도 문제될 것이 없다. 왜? 앞서 설명한 구조를 이해한다면 어렵지 않기 때문이다. 프로퍼티의 상속과 오버라이드에 대한 일반적인 규칙은 다음과 같다.

  1. 저장 프로퍼티를 위한 내부 저장공간은 변하지 않는다. 상속을 통해서 부모 클래스의 기존 메모리 구조를 변경할 수는 없기 때문이다.
  2. 따라서 계산 프로퍼티를 저장 프로퍼티로 오버라이드 할 수 없다.
  3. 읽기/쓰기를 위한 접근자는 오버라이드할 수 있다. 접근자를 오버라이드할 때에는 override 키워드를 써야 한다.
  4. 프로퍼티 오버라이드에서 부모 클래스의 프로퍼티 접근자를 호출하는 것은 선택적이다. 단, 저장 프로퍼티를 오버라이드하는 경우에는 super.prop 과 같은 식으로 부모의 프로퍼티 접근자를 사용해서 저장 공간에 액세스해야 한다.

그런데 저장 프로퍼티 기준으로 프로퍼티 접근자는 2개가 존재한다. 만약 이를 getter만 오버라이드하는 것은 허용되지 않는다. 즉 읽기-쓰기가 가능한 프로퍼티는 반드시 읽기-쓰기용으로 오버라이드해야 하며, 읽기 전용으로 만들 수 없다. 반대로 읽기전용의 프로퍼티는 읽기-쓰기로 오버라이드가 가능하다. 따라서 읽기/쓰기와 관련된 오버라이드의 추가 규칙은 다음과 같다. (그리고 이것은 중요하다.)

  1. 특정 프로퍼티에 대해 부모 클래스가 setter를 정의하고 있다면, getter를 오버라이드할 때 반드시 setter도 같이 오버라이드해야 한다. 동작이 수정되지 않는다면 super.prop = newValue 라고 작성한다.
  2. 참고로 애초에 setter만 갖는 프로퍼티를 작성하는 것은 불가능하다. Swift는 이 규칙을 강제하여 여러분이 변태가 되는 것을 방지한다.
  3. 반대로 읽기 전용 프로퍼티는 읽기-쓰기 프로퍼티로 오버라이드할 수 있다. 해당 클래스의 이후 자식 클래스는 모두 1을 준수해야한다.

요약하자면 부모의 프로퍼티가 쓰기 가능하고, 이것을 오버라이드한다면 setter는 무조건 오버라이드해야 한다는 것이다. Swift 컴파일러는 getter/setter의 개수를 보고 이 프로퍼티가 읽기 전용인지 쓰기가 가능한지를 판단한다. 따라서 부모의 쓰기 가능한 프로퍼티를 오버라이드하면서 getter만 작성하고 setter가 상속되기를 기대하는 것을 컴파일러는 읽기 전용으로 오버라이드하는 시도로 받아들이는 부분을 주의하자.

그런데, 옵저버는?

Swift의 프로퍼티에는 옵저버가 추가될 수 있다. 쓰기 가능한 프로퍼티에 대해서 willSet, didSet 블럭을 작성하면 프로퍼티 값이 변경되기 직전/직후에 미리 지정한 동작을 수행할 수 있다.

결론부터 말하면 프로퍼티의 옵저버는 상속되지 않으며, 오버라이드 할 수도 없다. Foo 라는 클래스를 상속받아 Bar 라는 자식 클래스를 작성한다고 해보자. 이 때 Foo의 x 라는 프로퍼티에 옵저버가 정의되어 있다고 하자.

Bar.x 의 setter 접근자를 오버라이드하면서 super.x 를 호출하여 값을 변경하지 않는다면 Bar.x를 변경할 때 어떠한 추가 동작도 일어나지 않는다. super.x를 통해 호출되는 Foo.x 의 setter가 실행될 때, Foo.x.willSet / Foo.x.didSet이 호출될 뿐이다. 따라서 상속 단계에서는 자신의 단계에서 호출되는 setter의 앞/뒤로 독립적인 옵저버를 설정해야 하며, 부모의 옵저버가 호출될지 여부는 super.prop의 setter가 호출되는지에 달렸다.

다음 예시는 프로퍼티 상속시에 옵저버가 상속되지 않는 것을 단적으로 보여준다.

  1. Bee.x 는 저장 프로퍼티이며, didSet 옵저버를 가지고 있다.
  2. Pou는 x를 오버라이드하면서 계산 프로퍼티로 변경했다. 이 때, setter는 super.x를 액세스하지 않는다.
class Bee:
  var x: Int = 5 {
    didSet { print("x has been changed") }
  }
}

class Pou: Bee {
  var a: Int = 10
  var b: Int = 2
  override var x: Int {
    get { return a + b }
    set { a = newValue % 10
          b = newValue - a }
  }
}

let p = Pou()
p.x = 55

정리

지금까지 Swift의 클래스에 프로퍼티를 선언하고, 서브 클래스에서 어떻게 오버라이딩하는지를 살펴보았다. 상속과 명시적인 오버라이드 등에서 여러 복잡한 상황을 맞딱뜨릴지 모르지만 앞에서 언급한 원리와 규칙만 이해한다면 어렵지 않게 이해할 수 있을 것이다.

다만 한가지 참고할 부분을 덧붙이자면, 공식 문서에서는 이니셜라이저 내에서 옵저버가 붙은 프로퍼티 값을 변경하는 것은 옵저버를 호출하지 않는다고 설명되어 있지만 여기에는 몇 가지 단서가 붙어야 한다. 먽 지정 이니셜라이저에서 현재 클래스에서 도입한 프로퍼티의 초기값을 변경할 때만 옵저버가 실행되지 않는다. 편의 이니셜라이저 내에서 지정 이니셜라이저를 호출한 후에 변경하는 것은 옵저버 실행을 유발한다. 즉 2단계 초기화 중에서 1단계(모든 멤버의 초기값 세팅) 이후에 발생하는 프로퍼티 값 변경은 이니셜라이저 내에서도 옵저버 블럭을 실행하게 하는 점을 참고하자.