이니셜라이저 – Swift

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

  • 객체 인스턴스가 사용할 메모리 공간을 확보하고 할당한다. 객체 인스턴스가 사용하는 메모리 크기는 기본적으로 인스턴스가 잡고 있어야 하는 크기가 있을 것이고, 여기에 해당 인스턴스가 가질 저장 프로퍼티들을 위한 저장공간이 추가된다. C로 치자면 malloc()과 같은 함수가 호출되어 해당 인스턴스가 자리잡을 수 있는 크기의 메모리 공간이 확보된다.
  • 확보된 메모리 공간 내부를 구성한다. 어떤 영역에 어떤 데이터가 들어갈 것인지 등등이 결정된다.
  • 준비된 영역 들 중에서 인스턴스의 여러 상태에 대한 초기 값을 세팅한다. 이니셜라이저가 하는 일은 이 부분의 저장 공간에 적절한 초기값을 넣어주는 일이다.

초기화 과정에서 가장 중요한 것은 해당 객체의 내부 구조를 위해 할당되는 모든 저장공간에 알맞은 초기값이 배당되는 것이다. 이 과정은 이니셜라이저라고 하는 특별한 메소드에 의해서 수행되며 (혹은 타입 내부의 프로퍼티 선언 구문에 의해서 디폴트값이 지정될 수도 있다.), Objective-C와는 많은 측면에서 차이를 보인다. 다음은 Swift의 이니셜라이저의 특징과 작성 규칙을 정리한 것이다.

  • Objecitve-C와 달리 Swift의 이니셜라이저들은 값을 리턴하지 않는다. (예외적으로 실패가능한 이니셜라이저들은 nil을 리턴하지만, 초기화에 성공한 경우에는 어떤 것도 리턴하지 않는다.)
  • 해당 클래스에서 새로 도입된 모든 저장 프로퍼티들은 알맞은 초기값을 가져야 한다.
  • 클래스는 최소 1개의 지정 이니셜라이저를 가져야 한다. (구조체의 경우 memberwise한 이니셜라이저가 자동으로 생성된다.)
  • 클래스에서도 모든 저장 프로퍼티가 초기값을 갖는 경우, 이니셜라이저 작성을 생략할 수 있다. 이 때 특정 클래스를 서브클래싱한 경우에는 부모클래스의 모든 이니셜라이저들을 자동으로 상속받는다.
  • 그렇지 않은 경우, 최소 1개의 지정 이니셜라이저를 작성해야 한다.
  • 서브 클래스가 자신만의 새로운 지정 이니셜라이저를 갖는 경우, 부모의 이니셜라이저는 상속되지 않는다.
  • 또, 서브 클래스가 부모의 지정 이니셜라이저를 1개 이상 오버라이드하는 경우, 부모의 나머지 이니셜라이저는 상속되지 않는다.
  • 서브 클래스에서 정의된 저장 프로퍼티는 해당 레벨의 초기화 과정에서 최우선적으로 초기화되어야 한다.
  • 서브 클래스의 지정 이니셜라이저는 부모 클래스의 지정 이니셜라이저를 반드시 호출해야 한다.
  • 상속받은 저장 프로퍼티의 초기값은 부모의 지정 이니셜라이저를 호출한 후에 변경할 수 있다.

클래스의 인스턴스는 초기화메소드[^1]를 통해서 생성되고 초기화된다. 사실 init~으로 시작하는 이 초기화 메소드들은 일반적인 메소드와는 구분되어야 하기 때문에 특별히 “이니셜라이저”라고 하겠다. 초기화 과정을 마친 객체 인스턴스는 비로소 사용할 준비가 완료된 상태가 된다. 여기서 준비가 완료되었다는 의미는 해당 클래스의 “모든 저장 프로퍼티를 위한 스토리지가 쓰레기 값이 아닌 올바른 초기값을 갖고 있는 것이 보장된다”는 뜻이다.

Objective-C에서의 초기화 과정은 이와 사뭇 다르다. Objective-C 클래스의 프로퍼티는 두 가지로 구분될 수 있는데, 하나는 C의 원시 데이터타입을 그대로 쓰는 경우가 있다. 또 다른 Objective-C 클래스의 인스턴스를 프로퍼티로 가지는 경우가 있다. 두 경우 모두에서 사실 별다른 초기화 과정이 없더라도 생성된 객체는 즉시 사용이 가능하다. 왜냐하면 LLVM 컴파일러가 각 프로퍼티의 backing storage 변수에 대해서 0 혹은 nil 값을 자동으로 부여하기 때문에 모든 프로퍼티는 적절하지는 않을지언정, 모든 메모리공간은 안전하게 사용할 준비가 된다.

Swift의 컴파일러는 이러한 초기화를 자동으로 처리해주지 않는다. 또한 프로퍼티의 선언 타입에 따라서 어떤 프로퍼티에 대한 storage variable은 초기화 이후에는 변경이 불가능할 수도 있다. 즉 최소한 각 프로퍼티에 디폴트값을 제시하거나 초기화과정에서 적절한 값을 갖도록 준비해주어야 한다는 말이다.

객체의 초기화와 관련하여서는 객체를 생성하기 전에 프로퍼티를 선언하는 시점에서 디폴트값을 지정해주는 것이 가장 좋은 전략이다. 하지만 객체를 만드는 시점에서 그 값이 결정되는 프로퍼티라면, 이니셜라이저를 통해서 초기화 시점을 잡아주어야 한다. 예외적으로 액세스 빈도가 낮거나, 초기화에 많은 비용이 들어가는 프로퍼티가 있다면, 그 초기화를 객체의 초기화 시점 이후로 미루는 “느긋한 프로퍼티”로 만드는 방법도 있다. 느긋한 프로퍼티는 객체의 초기화 종료 후에 초기값이 만들어지는 예외적인 케이스이며, 이는 문맥상 ‘처음에는 쓰레기값이었을’ 초기값을 자동으로 변경하는 셈이니 var로만 선언할 수 있다.

초기화와 안정성

Swift의 컴파일러는 할당한 메모리 공간을 모두  0으로 채우는 방식의 자동 초기화를 처리하지 않기 때문에, 적절한 초기화 방법을 정의하는 것은 중요한 일이 되었다. 특히 구조체나 열거체의 경우에는 비교적 그 구조가 간단한데,  클래스의 경우에는 상속이 가능하다는 특성 때문에 초기화 과정에서 수많은 시나리오가 파생될 수 있고, 이 경우 성공적인 초기화가 수행되었는지를 검증하는 문제가 매우 복잡하고 까다로운 문제가된다. 따라서 Swift는 클래스의 초기화 과정을 특정한 시나리오로 한정하여,  런타임에 생성되는 객체에 대해서 모든 프로퍼티를 액세스하는 것이 안전하다고 보장되는 초기화를 수행하기를 강제하려 한다. 따라서 프로퍼티 초기화 작업 자체에서 일련의 순서나 규칙이 적용된다. 이러한 규칙은 다소 답답하게 여겨질 수도 있다.

임의의 클래스의 인스턴스에 의해서 사용되는 메모리 공간의 크기를 결정하는 요인은 크게 두 가지인데, 첫번째로 해당 클래스 자체의 프로퍼티들의 수와 타입이다. 두 번째는 그 클래스의 부모 클래스이다. 어떠한 부모 클래스를 상속받는 클래스가 필요로 하는 메모리 공간은 우선 그 부모 클래스가 필요로하는 공간에, 자기 차례에 와서 새롭게 추가된 프로퍼티들을 위한 공간이 더해져서 계산된다. 물론 어떤 클래스들은 부모 클래스 없이 그 스스로가 계통의 루트 클래스가 될 수도 있다. 어쨌든 이렇게 상속될 수 있음을 고려할 때, 안전한 초기화 과정은 다음의 두 과정을 필수적으로 거쳐야 한다.

  1. 자기 자신이 정의한 프로퍼티는 반드시 초기값을 할당한다.
  2. 1이 끝난 후에는 그 부모에 의해 정의된 프로퍼티에 초기값을 반드시 할당한다.

따라서, 어떤 클래스의 이니셜라이저는 먼저 자신이 정의한 프로퍼티들이 모두 초기값을 갖도록 보장한 다음, 부모 클래스에 의해서 할당된 공간을 ‘반드시’ 채우게 한다. 부모 클래스에 의해서 할당된 공간을 ‘반드시’ 채우는 가장 좋은 방법은 부모 클래스의 이니셜라이저를 호출하는 것이다. 이렇게 모든 프로퍼티에 초기값을 채워 빈공간이 없도록 만든 다음에야, 이니셜라이저는 상속받은 프로퍼티의 초기값을 오버라이드 할 수 있다. 만약 이 순서를 어기게 된다면 어떻게 될까?

  1. 자기 자신이 정의한 프로퍼티를 다 채우지 않고 부모의 이니셜라이저를 호출한다 > 컴파일러가 에러를 낸다.
  2. 부모가 정의한 프로퍼티를 미리 채운 다음, 부모의 이니셜라이저를 호출한다. > 부모의 이니셜라이저가 자식이 써둔 값을 덮어써 버려 올바르게 오버라이드 되지 못한다.

이니셜라이저 상속과 오버라이딩 (번역)

Objective-C에서의 서브클래싱과 달리, Swift에서는 서브 클래스가 수퍼 클래스의 이니셜라이저들을 자동으로 상속하지 않는다. 서브클래싱을 한다는 것은 부모클래스에 부가적인 기능이 더해진다는 맥락이 있으므로, 부모클래스의 이니셜라이저보다 자식 클래스의 이니셜라이저가 더 복잡해져야 하며 따라서 자동으로 이니셜라이저를 상속받게되면 완전하게 혹은 올바르게 초기화된 인스턴스를 생성하는데 쓰일 수 없다는 것이 Swift의 관점이다.

물론 특정한 조건이 만족되는 경우에는 이니셜라이저들이 상속될 수 있다.

보통은 Swift의 서브 클래스가 수퍼클래스와 같은 하나 혹은 그 이상의 이니셜라이저를 갖기를 원하면 서브클래스 내에서 그 이니셜라이저에 대한 별도의 구현을 제공해야 한다. 만약 서브 클래스에서 수퍼 클래스의 지정 이니셜라이저에 대응하는 이니셜라이저를 작성한다면, 결과적으로 해당 지정 이니셜라이저에 대한 오버라이드를 제공하고 있는 것이다. 따라서 이 경우에 override 라는 변경자를 이니셜라이저 정의 앞에 사용해야 한다. 이는 부모 클래스가 자동으로 얻게된 디폴트 이니셜라이저를 오버라이드할 때에도 동일하게 적용되는 규칙이다.

(디폴트 이니셜라이저를 오버라이딩하는 예)

오버라이드된 프로퍼티나 메소드에서처럼 override 변경자는 Swift에게 이에 대응하는, 오버라이드될 수퍼클래스의 지정 이니셜라이저가 있는지를 체크하게 한다. (심지어 서브 클래스에서는 이 이니셜라이저가 편의 이니셜라이저로 상속되더라도 override를 붙여야 한다.)

반대로 서브클래스에서 부모의 편의클래스에 매칭되는 이니셜라이저를 작성한다면, 부모의 그 이니셜라이저는 결코 호출될 일이 없을 것이다.  이 경우에서는 서브 클래스가 엄밀히 따지면 부모의 것을 오버라이드하는 것이 아니다. 결과적으로 부모의 편의 이니셜라이저와 같은 이름을 쓰는 경우에는 override를 쓰지 않아야 한다. 다음은 애플 공식 문서의 한 예이다.

class Vehichle {
  // 모든 프로퍼티가 적절한 초기값을 가지기 때문에 init을 생략할 수 있다. 
  var numberOfWheels = 0
  var description: String {
    return "\(numberOfWheels) wheel(s)"
  }
}

class Bicycle: Vehicle {
  // 부모클래스는 명시적인 init을 가지지 않지만, 
  // 프로퍼티 규칙에 의해서 생략되었을 뿐이므로
  // init()을 만드려면 반드시 오버라이드여야 한다. 
  override init() {
    super.init()
    numberOfWheels = 2
  }
}
  1. 부모 클래스에서 모든 저장 프로퍼티는 초기값을 가지고 있다. 따라서 이니셜라이저를 작성하지 않더라도 디폴트 이니셜라이저가 생긴다.
  2. 서브 클래스는 새로운 저장 프로퍼티를 전혀 도입하지 않았다.
  3. 하지만 바퀴의 개수를 적절하게 초기화하기 위해서 별도의 기본 이니셜라이저를 작성한다.
  4. 이 때 부모클래스는 디폴트로 init()을 가지고 있을 것이기 때문에 override를 써야 한다.
  5. 초기화 순서에 따라 super.init()을 호출한 다음에 상속받은 프로퍼티를 설정한다.

만약 위 예에서 numberOfWheels 가 let 으로 선언되었다면 서브 클래스에서는 이 값을 결코 변경할 수 없다. (Vehicle의 초기화 과정에서 0으로 고정되고, 이후 변경이 불가하므로)

자동 이니셜라이저 상속

서브클래스는 기본적으로 부모의 이니셜라이저를 상속받지 않는다고 했다. 하지만 수퍼클래스의 이니셜라이저는 특정한 조건 하에서는 자동으로 서브클래스로 상속된다. 이는 실제로 많은 시나리오에서 직접 이니셜라이저를 오버라이드할 필요가 없다는 의미이고, 최소한의 노력으로 이니셜라이저를 안전하게 상속받을 수 있는 방법이 있다는 뜻이다. 이러한 많은 시나리에서 유의할 점은 “새 클래스에서 추가되는 새로운 프로퍼티는 모두 적절한 디폴트값을 가져야한다”는 조건이 붙는다는 것이다.

자식 클래스에서 새롭게 소개되는 저장 프로퍼티에 모두 디폴트값을 부여했다면 다음의 룰이 적용된다.

  1. 서브 클래스가 새로운 지정 이니셜라이저를 하나도 제공하지 않는다면 이 때 부모 클래스의 모든 지정 이니셜라이저들이 상속된다.
  2. 만약 서브 클래스가 부모의 모든 지정이니셜라이저를 오버라이드했거나, 상속받았다면 편의 이니셜라이저들도 자동으로 상속된다.

또 부모의 지정 이니셜라이저 중 하나를 자식이 편의 이니셜라이저로 오버라이드한 경우도 2의 일부가 될 수 있다. (단 이것도 오버라이드이기 때문에 부모가 2개 이상의 이니셜라이저를 가지고 있다면 나머지 하나도 오버라이드해야, 편의 이니셜라이저를 상속받을 수 있다.)

class Food {
  var name: String
  init(name: String) {
    self.name = name
  }
  convenience init() {
    self.init(name: "[Unnamed]")
  }
}

class RecipeIndredient: Food {
  var quantity: Int

  init(name: String, quantity: Int) {
    self.quantity = quantity
    super.init(name: name)
  }

  // init(name:)은 부모의 지정 이니셜라이저이지만
  // 편의 이니셜라이저로 오버라이드한다. 
  override convenience init(name: String) {
    self.init(name: name, quantity: 1)
  }

  // 따라서 편의 이니셜라이저인 init()은
  // 자동으로 상속된다.
}

위 예제에서 다시 확인해보자.

  1. Food 는 저장 프로퍼티에 대해 기본값이 없으므로 지정 이니셜라이저 init(name:) 을 갖는다.
  2. RecipeIngredientFood를 상속하면서 저장 프로퍼티를 추가했고, 여기에 필요한 지정 이니셜라이저 init(name:quantity:)를 새로 작성했다.
  3. 따라서 지정 이니셜라이저가 자동으로 상속되지 않는다.
  4. 그래서 init(name:)편의 이니셜라이저로 상속받았다.
  5. 새로운 지정이니셜라이저 + 부모의 모든 지정 이니셜라이저를 오버라이드하였으므로 부모의 편의 이니셜라이저 init()을 자동으로 상속한다.

다음의 예는 어떤가?

class ShoppingListItem: RecipeIngredient {
  var purchased = false
  var description: String {
   var output = "\(quantity) x \(name)"
   output += purchased ? " v" : " x"
   return output
  }
}

위 코드는 RecipeIngredient를 상속받은 ShoppingListItem이다.

  1. 두 개의 프로퍼티가 추가되었는데, 하나는 디폴트 값이 있는 저장 프로퍼티, 다른 하나는 계산 프로퍼티이다.
  2. 따라서 별도의 지정이니셜라이저가 필요하지 않고, 오버라이드하지도 않았다.
  3. 그래서 부모의 모든 지정 이니셜라이저가 상속되었고
  4. 자연스럽게 편의 이니셜라이저도 모두 상속되었다.