이니셜라이저 – Swift

Swift의 클래스와 구조체, enum 객체들은 사용하기 전에 반드시 초기화되어야 한다. 그러면 초기화(initialization)이란 무엇인가? 객체의 생성 자체를 초기화과정에 포함시키는 관점과 그렇지 않은 관점이 있지만, 여기서는 “객체를 만들어서 사용가능한 상태로 준비하는 일”이라고 보자. let foo = Foo() 와 같이 특정한 타입의 인스턴스를 생성하는 구문을 실행했을 때 저 아래(?)에서 벌어지는 과정은 다음과 같다.

이니셜라이저 – Swift 더보기

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

프로퍼티에 대한 이야기를 몇 번 하긴하였는데, 실제로 프로퍼티 상속은 실제로 상당히 오묘하게 헷갈릴 수 있는 여지가 많아서 다시 한 번 정리하는 차원에서 예제와 함께 포스팅한다. 이 글에서는 클래스의 프로퍼티가 서브클래싱할 때 어떤식으로 처리되는지를 살펴볼 것이다. 먼저 저장 프로퍼티와 계산 프로퍼티에 대해서 살펴보자. 다음 예제 코드는 정수형 프로퍼티 4개를 가지고 있는 클래스 Foo를 구현한 예이다.

class Foo {
  var a: Int = 5
  var b: Int = 30 {
    willSet { print("[Foo] property b will be changed.") }
    didSet { print("[Foo] property b has been changed.") }
  }
  var c: Int { return a * b } 
  var d: Int {
    get {
      return a + b
    }
    set {
      a = newValue % 10
      b = newValue - a
    }
  }
}
  • Int 타입의 a는 가장 단순한 형태의 저장 프로퍼티이다. 클래스 Foo는 하나의 Int 타입 값을 위한 스토리지 공간을 할당한다. 그리고 해당 공간에 값을 읽고 쓸 수 있는 getter, setter 메소드를 자동으로 생성한다. 물론, 이 메소드들은 프로그래머에게 노출되지 않으며,  foo.a = 1 과 같은식으로 점 표기(dot notation)를 쓰는 것으로 대신하게 된다.
  • b 는 역시 Int 타입의 저장프로퍼티이다. a 와 동일하게 구성되겠지만, 값이 변하기 직전, 직후에 willSet, didSet에서 정의해준 옵저버 코드 블럭이 실행될 것이다. 여기서는 값이 변하는 순간 전후에 프로퍼티 b가 변경된다는 메시지를 출력하게 했다.
  • c는 가장 일반적인 계산 프로퍼티형태로, a, b 의 값을 곱한 것을 결과로 리턴하는 읽기 전용의 프로퍼티이다.
  • d는 계산 프로퍼티이면서 setter를 지원한다. 이 경우 두 개의 접근자를 get{ ... } set { ... } 형태로 분리해서 적는다. (두 블럭 사이에 컴마와 같은 연결 문법은 필요없다.) d의 동작은 입력된 정수값에서 1의 자리 숫자를 a에, 그외 값을 b로 나누어 저장하며, getter 호출시에는 나누어 저장된 두 값을 더하여 리턴한다.

실제로 Foo 클래스의 인스턴스를 만들어서 확인해보자.

let f = Foo()
foo.a = 7
foo.b = 30 
// [Foo] property b will be changed.
// [Foo] property b has been changed.

print(f.a, f.b, f.c, f.d)
// 7   30   210   37

f.d = 84
// 80 + 4로 분해되어 b = 30, a = 4가 된다.
print(f.a, f.b, f.c, f.d)
// 4   80   820   84

여기까지는 사실 Objective-C의 선언프로퍼티와 거의 같은 개념이기 때문에 (옵저버만 빼고, Objective-C에서는 setter를 직접 구현해야하기 때문에 옵저버가 따로 필요없다) 본격적으로 헷갈리기 시작할 수 있는 부분은 바로 상속에 대한 부분이다. Foo 클래스를 상속받은 Bar 클래스를 다음과 같이 작성하면서 각각의 프로퍼티를 어떻게 상속하는지 살펴보자.

class Bar : Foo { 
  // #1 - a를 computed property로 오버라이드
  override var a: Int {
    get { return super.a * 10 }
    set { super.a = newValue / 10 }
  }

  override var b: Int {
  // #2 - Bar의 옵저버를 추가로 설치
     willSet { print("[Bar] property b will be changed.") }
     didSet { print("[Bar] property b has been changed.") }
  }

  // #3 c에 대한 오버라이드는 없음.

  override var d: Int {
    get {  return super.d } // #4
    set {  // #5
      a = newValue % 100
      b = newValue - a
    }
  }
}

프로퍼티는 값의 저장공간과 접근자 메소드들의 집합이라 볼 수 있고, 프로퍼티를 오버라이드한다는 것은 getter/setter 및 옵저버 메소드들을 오버라이드하는 개념으로 이해할 수 있다.

저장 프로퍼티를 오버라이드하기

Foo의 a는 저장 프로퍼티였는데, Bar에서는 이를 오버라이드하여 별도의 getter, setter를 정의하고 있다. Bar에는 a를 위한 저장공간을 할당하게끔하고 있고 이는 Foo로부터 상속받은 속성이 된다. 대신에 Bar는 저장소를 액세스할 때 임의의 변경을 거치는데 그 내용은 다음과 같다.

  1. Foo에서 저장공간에 액세스하는 메소드는, 저장공간에 있는 값을 그대로 리턴한다. Bar에서는 이를 오버라이드하여 저장공간에 들어있는 값(이는 super.a를 통해서 액세스할 수 있다.)을 10배로 늘려서 리턴한다.
  2. 따라서 처음 초기화했을 때, a를 액세스하면 5대신 50이라는 값을 얻게 된다.
  3. setter에서는 주어진 값을 10으로 나눈 몫을 super.a를 이용해서 저장한다.
  4. 이 setter/getter 관계에 의해서 54라는 값을 대입하려하면 5라는 값이 저장공간에 저장되고, 다시 액세스하면 50이라는 값을 얻게된다. 즉 접근자에 의해서 1자리에 대한 정밀도가 사라지는 효과를 얻게된다.

옵저버에 대한 오버라이드

부모 클래스의 프로퍼티가 저장 프로퍼티이기만 하면 서브 클래스에서 옵저버를 오버라이드할 수 있다. 그런데 이 옵저버는 “현재 서브 클래스에서 setter의 전/후에 호출된다”는 것을 알아두자. 따라서 부모 클래스에서 만들어놓은 옵저버를 무시할 수 없게 된다. Bar의 인스턴스에 대해서 b 값을 변경하려하면 다음과 같은 동작을 수행하게 된다.

  1. Bar의 setter가 호출되기 전에 willSet이 호출된다.
  2. Bar의 setter는 별다른 오버라이드가 없기 때문에 자동으로 super.b = newValue를 호출하는 것으로 기본 구현이 적용된다.
  3. Foo의 setter가 호출되려할 것이고, 호출 전에 Foo willSet이 호출된다.
  4. Foo의 setter에 의해 값이 변경된다.
  5. Foo의 didSet이 호출된다.
  6. 다시 Bar의 didSet이 호출된다.

참고로 옵저버들은 개별 클래스의 해당 컨텍스트에서만 의미를 가지며, 별도의 setter 구현이 없으면 기본적으로 super.prop = newValue가 되므로 부모의 옵저버는 서브 클래스로 상속되지 않지만, setter 체인을 타고 거슬러 올라가서 부모의 옵저버가 호출될 것이다.

부모의 옵저버가 상속되지 않는다는 것은 옵저버를 가진 저장 프로퍼티를 계산 프로퍼티로 상속해보면 알 수 있다.

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

위 예에서 오버라이드된 x는 super(Bee)의 x에 대한 setter를 호출하지 않는다. 따라서 Bee의 프로퍼티 옵저버는 호출되지 않으며, 옵저버에 대한 오버라이드를 제공하지 않았으므로 p.x 값이 바뀔 때에는 어떤 문구도 출력되지 않는다.

계산 프로퍼티에 대한 오버라이드

계산 프로퍼티 작성에 관해서는 두 가지 주의해야 할 규칙이 있다.

  1. setter가 있는 프로퍼티에 대해서는 반드시 getter를 작성해야 한다. 즉 write-only라는 프로퍼티를 만들 수는 없다.
  2. read-write 계산 프로퍼티를 서브 클래스에서 read-only로 오버라이딩할 수 없다. 만약 setter의 동작만 바꾸려는 경우에도 getter는 명시적으로 오버라이딩해야 한다.

프로퍼티의 접근자는 getter와 setter가 내부적으로 구분되어 있지만, 외부에서는 이 둘을 묶어서 하나로 본다. 즉 모두 구현되어 있느냐, getter만 구현되어 있느냐는 것은 해당 프로퍼티가 read-only인지 read-write인지를 구별하는데 사용된다. 따라서 계산 프로퍼티를 오버라이딩할 때에는 1)getter는 무조건 오버라이딩, 2) setter는 부모에서 정의되었다면 무조건 오버라이딩해야 한다.

참고로 부모클래스에서 계산 프로퍼티로 정의된 프로퍼티에 대해서 자식 클래스에서 저장 프로퍼티로 변경할 수 없다. (메모리 구조가 달라지기 때문이다.)

getter, setter를 갖는 계산 프로퍼티 오버라이드에서 super의 프로퍼티 접근자를 호출하는 것은 선택적이다. 위 예에서 Bar의 d에 대한 getter는 super.d를 리턴하는 것으로 되어 있다. (그리고 이 getter는 위의 두 번째 규칙에 의해서 반드시 오버라이드해야 만 한다.)이를 분석해보자.

  1. Bar.d-getter는 return super.d 로 부모클래스의 구현을 그대로 사용한다.(즉 자동으로 상속하는 것은 아니다.)
  2. 부모 클래스의 구현은 return a + b 이다.
  3. 그런데 이때 a, bBar의 컨텍스트에서 실행된다. (어떠한 값도 변경하지 않았다면) a + b는 5 + 30 (Foo의 기본값이자, Foo에서의 접근자로 얻은 값들)이 아니라 50 + 30(bar.a + bar.b) 이 되어 80이 리턴될 것이다.

setter의 경우에는 자신의 다른 프로퍼티 값 변경을 위해서 다른 접근자를 호출한다. d = 524를 실행할 때의 동작을 따라가보자.

  1. Bar.d의 setter가 호출된다. 이 때, 암묵적으로 변경될 새값은 newValue라는 이름을 달고 전달된다.
  2. a = newValue % 100이 실행된다. 이 때의 컨텍스트는 Bar 이므로 Bar.a = 24가 된다.
  3. Bar의 a에 대한 setter는 다시 이 값을 10으로 나눠서 2라는 값을 저장소에 저장한다. (super.a = 24/ 10)
  4. 다시 d 의 setter로 돌아와서, b에는 newValue - a를 대입한다. 이 때 a는 얼마일까? 24? 땡! 저장소에 저장할 때 10을 나눠서 저장했고, 이 값을 다시 10배해서 가져오므로 20이다. 즉 524 – 20 이 되어 504를 세팅하게 된다.
  5. 그런데 b는 willSet 옵저버가 달려있는 프로퍼티이다. 따라서 “[Bar] b will be changed”가 출력된다. 그리고 super.b = 504가 호출될 것이다.
  6. Foo의 b 역시 willSet 옵저버가 달려있다. “[Foo] b will be changed”가 출력된다.
  7. b의 값이 변경된다.
  8. Foo의 didSet옵저버가 호출된다. “[Foo] b has been changed”가 출력된다.
  9. 다시 Bar의 setter가 실행을 완료했다. didSet이 호출되면서 “[Bar] b has been changed”가 출력된다.

이렇게 변경이 끝난 후 a, b, c, d 값을 각각 get해서 출력해보면 다음과 같다.

  1. a의 실제 저장값은 2이며, 20이 리턴된다.
  2. b에는 504가 저장되어 있고, 504가 리턴된다.
  3. c는 두 값을 곱한 값이므로 10080이 리턴된다.
  4. d는 두 값을 더한 값이므로 524가 리턴된다.

정리

지금까지 Swift의 클래스에 프로퍼티를 선언하고, 서브 클래스에서 어떻게 오버라이딩하는지를 살펴보았다. 특히 오버라이딩 시에는 다른 프로퍼티에 대한 의존과 부모 클래스의 접근자를 참조하는 등의 상황이 겹쳐지면서 상당히 복잡한 경우가 생길 수 있는데, 다음과 같은 몇 가지 원리로 정리해서 접근하면 이해에 도움이 될 것이다.

  1. 저장프로퍼티와 계산프로퍼티는 내부적인 저장공간을 따로 마련하는가의 차이만 있을 뿐, 본질적으로 프로퍼티는 getter + setter의 접근자 조합으로 볼 수 있다.
  2. Swift는 write-only 프로퍼티를 작성할 수 없게 강제하여, 당신이 변태가 되는 것을 막아준다.
  3. 프로퍼티의 오버라이드는 결국 각 접근자의 오버라이딩이며, 따라서 저장 프로퍼티가 계산 프로퍼티로 오버라이딩되는 것은 문제가 없다. 단 서브클래스에서 부모 클래스와 같은 프로퍼티 이름에 대해서 추가적인 저장공간을 할당하는 것이 금지되어 있기 때문에 계산 프로퍼티를 저장 프로퍼티로 오버라이딩하는 것은 금지된다.
  4. 옵저버는 항상 현단계의 setter 앞뒤에 붙게 된다. 따라서 옵저버 메소드는 상속되지 않는다고 본다. 또한 커스텀 setter를 작성했다면 옵저버의 기능을 여기서 구현하면 되기 때문에 computed property에는 옵저버를 추가할 수 없다. 대신 이것은 커스텀 setter가 존재하는 클래스에 대한 이야기이기 때문에 계산 프로퍼티라도 상속받은 프로퍼티에 대해서는 옵저버를 추가할 수 있다.
  5. 공식 문서에서는 이니셜라이저 내에서 옵저버가 붙은 프로퍼티 값을 변경하는 것은 옵저버를 호출하지 않는다고 설명되어 있지만 여기에는 몇 가지 단서가 붙어야 한다.
    1. 지정 이니셜라이저에서 현재 클래스에서 도입한 프로퍼티의 초기값을 변경할 때만 옵저버가 실행되지 않는다.
    2. 편의 이니셜라이저 내에서 지정 이니셜라이저를 호출한 후에 변경하는 것은 옵저버 실행을 유발한다.
    3. 부모 클래스의 이니셜라이저를 호출한 후에 상속받은 프로퍼티의 값을 변경하는 것 역시 옵저버 실행을 유발한다.