먹고 기도하고 코딩하라

Swift의 기본 문법 (4) - 구조체와 클래스, 열거형 본문

앱/Swift

Swift의 기본 문법 (4) - 구조체와 클래스, 열거형

2G Dev 2021. 11. 25. 21:17
728x90
728x90

 

저번 포스팅에서 함수와 어려운 클로저, 옵셔널 값, 조건/반복문을 살펴봤다.

이번에는 값들을 묶을 수 있는 구조체와 클래스, 열거형을 알아보자.

 

 

(8) 구조체

스위프트 구조체는 상속이 불가한 값 타입이다. 값 타입이란 상수, 변수에 전달될 때 값이 복사되어 전달되는 것이지 레퍼런스가 넘어가는 것이 아니라는 뜻이다. 상속이 필요없을 때 쓰기 좋은 자료형이다. struct 키워드로 선언할 수 있다.

struct Sample {
  // 일반적인 인스턴스 저장 프로퍼티
  // 항상 같은 초기값을 갖는다면, 프로퍼티 선언과 동시에 값을 할당하는 것이 좋음 (기본 프로퍼티)
  var mutProperty: Int = 100;	// 가변 프로퍼티
  let immutProperty: Int = 200	// 불변 프로퍼티
  
  // 타입 저장 프로퍼티
  static var typeProperty: Int = 100	// 타입 프로퍼티
  
  // 인스턴스 연산 프로퍼티 (값 저장이 아닌 연산 목적의 프로퍼티)
  var instanceCal: Int {
    get {
      return mutProperty - 1
    }
    // set에서 이름을 안 쓰면 newValue가 인자 기본 이름이 됨
    set (inputValue) {
      mutProperty = inputValue + 1
    }
  }
  
  // 읽기전용 연산 프로퍼티 (get만 구현)
  var selfIntroduction: String = "Cool" {
    get {
      return "mutableProperty: \(mutProperty)";	//this 같은 거 쓰지 않음
    }
  }
  
  // 프로퍼티 감시자 사용 가능.
  // 저장된 값이 변경될 때 호출된다. 
  // 연산 프로퍼티 기능은 이 안에 들어갈 수 없다
  var observedWord: String {
    willSet(newWord) {
      print("변경 : \(observedWord) -> \(newWord)");
    }
    didSet(oldWord) {
      print("변경 후 : \(oldWord) -> \(observedWord)");
    }
  }
  
  // 인스턴스 메소드
  func instanceMethod() {
    print("instance");
  }
  
  // 타입 메소드
  static func typeMethod() {
    print("type");
  }
}

언뜻 보기에 클래스와 비슷하다. 사실 자료형과 클래스 선언 간에는 그렇게 큰 차이가 없는 게 사실이기도 하다.

하나하나 살펴보면, 크게 프로퍼티연산 프로퍼티, 메소드로 나눌 수 있다. 그리고 프로퍼티와 메소드에는 인스턴스와 타입이 있다. 인스턴스는 이 구조체 자료형을 새롭게 생성하면 그 생성된 하나의 커스텀 인스턴스마다 주어지는 것들이고, 타입은 이 구조체에 종속되어 이 구조체 틀에서 나온 것들이 모두 똑같이 공유하는 것이라고 생각할 수 있다. 이런 타입 프로퍼티/메소드에는 static 키워드가 붙는다. 이런 것들은 구조체 이름으로 그냥 접근할 수 있다.

프로퍼티는 변수/상수를 var/let 키워드로 나눠 선언할 수 있는데, var로 선언하면 가변, let으로 선언하면 불변 프로퍼티이다.

 

연산 프로퍼티

프로퍼티가 값을 담아두는 용도라면 인스턴스 연산 프로퍼티는 값의 계산이 목적이다. 실제 값을 저장하는 게 아니라 getter, setter로 값을 접근하고 간접적으로 다른 프로퍼티 값을 설정할 수 있게 하는 프로퍼티다. instanceCal을 보면, 일반 프로퍼티와 메소드를 섞어놓은 것처럼 생겼다. 실제로는 mutProperty 프로퍼티에 접근하고 그 값을 변경하는 역할을 한다. get 블록 안에는 instanceCal에 그냥 접근하면 반환되는 값, 그리고 set 블록 안에는 instanceCal에 어떤 값을 대입했을 때 수행할 문장들이 들어 있다.

읽기 전용은 get만 구현되어 있다. 말그대로 프로퍼티 값을 변경하지는 못 하고 그냥 읽기만 할 수 있다.

연산 프로퍼티를 선언할 때는 반드시 var로 선언해야 한다고 한다. 읽기 전용도 마찬가지다. 연산 프로퍼티는 읽기 전용이라도 연산 값에 따라 값이 변할 수 있기 때문에 var로 선언하는 게 옳다고 한다. 

 

메소드

인스턴스 메소드랑 타입 메소드는 워낙 직관적이라 따로 설명할 필요는 없을 것 같다.

인스턴스 메소드는 struct로 찍어낸 인스턴스에서 사용 가능한 메소드고, 타입 메소드는 struct 이름 자체로 접근해서 호출할 수 있는 메소드이다. 그러니까 특정 타입 자체에서 호출해 사용할 수 있는 메소드이다. 

구조체는 값 타입이기 때문에 기본적으로 인스턴스 메소드에서는 값 타입 var 프로퍼티를 변경할 수 없지만, mutating 키워드를 메소드 앞에 붙여주면 메소드 계산 후 원본 구조체에 결과를 덮어써 값을 변경할 수 있다. 물론, 그냥 점(.) 연산자로 바로 프로퍼티에 접근해서 값을 바꾸는 건 허용된다.

단, 이 두 경우 모두 let 프로퍼티에는 적용할 수 없다.

 

프로퍼티 감시자 (Property Observer)

프로퍼티 감시자란, 어떤 프로퍼티의 값이 변경될 때 자동으로 실행하게 하고 싶은 작업들을 묶어놓은 것이다. willSet, didSet 2가지 메소드를 갖는데 willSet은 변경된 값 저장 전에 실행되며 변경될 새로운 값을 매개변수(기본 이름 newValue)로 받고, didSet은 변경 직후 실행되며 변경 전의 원래 값(기본 이름 oldValue)을 매개변수로 받는 것이다.

당연한 말이지만 프로퍼티가 변경되는 것을 감시하는 것이므로, 상수나 읽기 전용 프로퍼티에는 감시자를 붙일 수 없다.

이제 한 번 만들어서 실험해보자.

var mutable: Sample = Sample()
mutable.mutableProperty = 200
print(mutable.immutableProperty)	// 200
// mutable.immutableProperty = 200 이건 안됨

print(mutable.instanceCal)	// 199
mutable.instanceCal = 5
print(mutable.mutableProperty)	// 6

print(mutable.selfIntroduction)	// mutableProperty: 6
mutable.instanceMethod()	// instance
Sample.typeMethod()	// type

var mutable2 = mutable;
mutable.mutableProperty = 50
print(mutable2.mutableProperty);	// 6

새롭게 찍혀 나온 struct 인스턴스의 프로퍼티나 메소드에 접근하려면 . 연산자를 쓰면 된다.

instanceCal을 쓰는 방법이 조금 특이하다. get으로 사용하고 싶을 때는 그냥 .으로 접근해서 쓰면 되는데 set으로 사용하고 싶을 때는 .으로 접근해서 거기에 값을 대입하면 된다. 메소드처럼 부르는 게 아니다! 그렇게 쓰면 에러가 난다. 

또한, 열거형은 값 타입으로 이미 생성된 인스턴스를 다른 변수나 상수에 대입하면 인스턴스 자체가 복사된다. 그렇기 때문에 mutable의 값을 변경해도 mutable2의 값은 영향을 받지 않는다. 

 

참고로, let으로 뭔가를 선언했다면 제아무리 struct 안에서 var로 선언했다고 해도 변경할 수 없다. 아예 상수 인스턴스이기 때문이다.

let immutable: Sample = Sample()
// immutable.mutProperty = 300	// 안 된다

 

한편, 타입 메소드와 프로퍼티는 그냥 인스턴스를 안 만들고 구조체 이름으로 접근이 가능하다. 타입 프로퍼티/메소드는 특정 타입에 속한 프로퍼티/메소드로, 해당 타입에 해당하는 단 하나만 생성된다. 특정 타입의 모든 인스턴스에 공통으로 사용되는 값을 정의할 때 쓰기 좋다. 타입 프로퍼티는 항상 초기값을 지정해야 한다.

Sample.typeProperty = 300
print(mutable.typeProperty);	// 300

프로퍼티 감시자도 실험해보자. observedWord를 다른 단어로 바꿔보자.

mutable.observedWord = "Day"
// 변경 : Cool -> Day
// 변경 후 : Cool -> Day

위에서 살펴본 연산 프로퍼티와 프로퍼티 감시자는 전역/지역변수에서 모두 사용 가능하다. 

 

 

(9) 클래스

값 타입인 구조체와 달리 클래스는 참조(reference) 타입이다. 상속이 불가능한 구조체, 열거형과 달리 클래스는 상속이 가능하다. 하지만 여러 클래스를 한꺼번에 상속받는 건 불가능하다. (다중 상속 불가). 뭐 이런 특징이 있다는 걸 알고 이제 예제를 살펴보자.

class Person {
  var name: String = "";	// 가변 프로퍼티
  var gender: String?	// 옵셔널 프로퍼티. 자동으로 nil로 초기화됨
  static let species: String = "human";	// 불변 타입 프로퍼티
  
  // 인스턴스 메소드로, 오버라이드 가능
  func introduce() {
    print("Hi, I'm \(name).");
  }
  
  // 타입 메소드 중 클래스 메소드. 오버라이드 가능
  class func classMethod() {
    print("type method : class");
  }
  
  // 타입 메소드 중 정적 메소드. 오버라이드 불가능
  static func staticMethod() {
    print("type method : static");
  }
  
  // final 메소드. 오버라이드 불가능
  final func finalMethod() {
    print("final method");
  }
}

let teresa: Person = Person()
teresa.name = "Teresa";
teresa.introduce();	// Hi, I'm Teresa.

구조체와 큰 차이는 없다. 구조체에서 쓸 수 있는 프로퍼티 감시자는 클래스에서도 역시 사용 가능하다. 좀 차이점이 있다면 class와 final 키워드가 붙은 메소드가 있고, 상속, 타입 캐스팅이 가능하며, 소멸자가 있고 참조 카운트(reference counting)가 가능하다는 점이다.

static과 class는 근본적으로 모두 타입 메소드이다. 인스턴스를 생성하지 않고도 클래스 이름으로 그냥 메소드 호출이 가능하다는 의미이다.

그런데 차이점은, class 키워드가 붙으면 하위 클래스에서 이것을 오버라이드할 수 있고 static 키워드가 붙으면 오버라이드가 불가능하다는 의미이다. 즉, 부모의 메소드를 고쳐 쓰지 못하고 그대로 써야 한다는 뜻이다.

그리고 눈치챘는지 모르지만! let으로 Person 인스턴스를 만들었지만, 클래스 내부의 프로퍼티 키워드가 var이라면 수정 가능하다. 구조체에서는 let으로 인스턴스 선언을 하면 프로퍼티가 var라도 수정이 불가능했다.

final 키워드는 서브클래스에서 이것이 오버라이드되는 것을 막기 위해 붙이는 키워드이다. 

 

클래스는 레퍼런스 타입이다. 그러므로, 지금 위에서 선언한 teresa를 다른 변수를 선언해 받으면 같은 레퍼런스를 가리키게 된다. 한 쪽에서 값을 바꾸면 같은 레퍼런스를 가리키는 다른 인스턴스의 값도 영향을 받게 되어 있다.

만약, 어떤 두 인스턴스가 같은지 구별하고 싶다면 식별 연산자(===)를 사용하면 된다. 식별 연산자는 참조를 비교하는 것이고, 동등 연산자(==)는 값을 비교한다는 차이가 있음을 알아두자.

 

이니셜라이저 (Initializer, 초기자)

지금 Person 클래스의 프로퍼티들에는 기본 저장값으로 임의의 값들이 들어가 있다(옵셔널 프로퍼티는 예외다). 이 기본값을 빼면 컴파일 에러가 나는데, 이유는 현재 클래스에는 커스텀 생성자 메소드(이니셜라이저, 이하 초기자)가 없기 때문이다. 모든 프로퍼티의 초기값이 설정돼 있고, 초기자가 없다면 스위프트가 알아서 기본 초기자를 만들어준다. 이제 생성자 메소드를 하나 추가해 보자.

// ... 생략
  init (name: String) {
    self.name = name
  }

초기자는 init 키워드로 쓴다. 그냥 init만 있으면 되고, 그 다음 바로 매개변수의 이름과 타입들이 들어온 다음 self로 접근해서 값들을 할당해주면 된다. 이렇게 초기자에서 값을 설정할 때는 프로퍼티 감시자가 호출되지 않는다. 초기자에서는 상수 프로퍼티에 값을 할당하는 것 역시 가능하다. 물론, 상수 프로퍼티에 아직 값이 없을 때 할당한다는 얘기다. 이미 초기값이 있다면 바꿀 수 없다.

그런데 이렇게 하면 gender를 표현할 수가 없다. 요즘은 구글 가입할 때 성별에 "공개를 원하지 않음" 하면서 이런 것도 있던데 뭐 이런 비슷한 게 있다고 치자. 반드시 필요한 정보는 이름, 나이이고 성별은 선택 사항이다. 이 경우에는 어떻게 해야 할까?

이 때 유용하게 쓸 수 있는 게 보조 생성자인 convenience init이다. 다음과 같이 쓸 수 있다.

// ... 생략
  init (name: String) {
    self.name = name
  }
  convenience init (name: String, gender: String = "unknown") {
    self.init(name: name)
    self.gender = gender
  }

자기 클래스의 init을 쓰려면 꼭 convenience 키워드를 붙여야 한다. 이렇게 하면, name, age, gender에 기본값을 주지 않아도 컴파일이 된다. 

스위프트의 초기자에는 3가지 규칙이 있는데, 첫 번째는 지정 초기자가 반드시 직계 부모 클래스의 지정 초기자를 호출해야 한다는 것이며(매개변수가 있는 init 초기자), 두 번째는 convenience init이 반드시 같은 클래스의 다른 초기자를 호출해야 한다는 것이고, 마지막으로는 convenience init이 궁극적으로 지정 초기자를 호출해야 한다는 점이다. 그러므로, convenience init에서는 반드시 self.init을 하든지 해서 꼭 초기자를 호출해야 한다.

 

자! 이제 상속을 봐야 하니까 Person을 상속받아서 다른 클래스를 만들어보자.

class Student: Person {
  // 이렇게 하면 반드시 있어야 하는 프로퍼티지만, init 때 값이 주어지는 게 아니라 나중에 주어짐을 나타냄
  var major: String! {
        willSet(newValue) {
            print("전과할 거래요")
        }
        didSet(oldValue) {
            print("\(self.major!)로 전과~")
        }
  }
  
  override func introduce() {
    super.introduce()
    print("Hi, I'm \(name) and \(major!) major.");
  }
}

다른 클래스를 상속받는 클래스를 만들려면, 콜론 뒤에 상속하려는 클래스 이름을 써주면 된다. 이렇게 클래스 상속을 하고 나면 부모의 프로퍼티와 메소드를 상속받고, 자기만의 프로퍼티와 메소드도 추가로 만들거나, 부모의 것을 오버라이드해서 새로 작성해서 쓸 수 있다. 오버라이드는 메소드뿐 아니라 프로퍼티, 프로퍼티 감시자에도 모두 적용 가능하다. 

기본적으로, 자식 클래스에서는 부모 클래스의 초기자를 상속받지는 않는다. 단 예외는 있다. 

여기서는 새로운 프로퍼티인 major와 원래 있던 인스턴스 메소드인 introduce를 오버라이드한 메소드를 하나 만들어봤다. (덤으로 프로퍼티 감시자까지 한 번^^)

introduce에서 override 키워드를 앞에 꼭 써줘야 하는데, 이 키워드를 빼먹으면 Overriding declaration requires an 'override' keyword 하면서 키워드 넣으라고 에러를 뱉는다. 오버라이드할 때는 override 키워드를 꼭 써주자. 

super.introduce는 써줘도 되고 안 써줘도 되지만 그냥 써봤다. 여기서 super란 자기가 상속받은, 즉 부모 클래스를 뜻하고, 여기서는 부모 클래스의 introduce를 실행하라는 뜻이 된다.

 

이제 학생을 하나 만들어보자.

let kendra: Student = Student(name: "kendra");
kendra.major = "Journalism"
print(kendra.gender ?? "unknown")	// unknown
kendra.introduce();	// Hi, I'm Kendra. Hi, I'm Kendra and Journalism major.
kendra.major = "CS"
// 전과할 거래요
// CS로 전과~

자 이번에는 새로운 사람을 만들어 보자. age를 넣어서 만들 건데, 살아있는 사람의 수명은 최장 120년 정도이므로 120살이 넘지 않도록 하는 사람 클래스를 만들어볼 것이다.

class LivingPerson: Person {
    var age: Int
    // 실패 가능한 이니셜라이저
    init?(name: String, age: Int) {
        if (0...120).contains(age) == false {
            return nil
        }
        self.age = age
        if name.isEmpty {
            return nil
        }
        super.init(name: name)
        self.name = name
    }
    
    // deinit은 클래스 인스턴스가 메모리에서 해제되는 시점에 호출됨
    // 인스턴스 해제 시점에 해야 할 일 구현
    deinit {
        print("Good bye!")
    }
}

초기값에 따라 인스턴스가 생성될 수도, 아닐 수도 있다면 init 메소드에 ?이 붙게 된다. 실패 가능한 생성자 메소드라는 뜻이다. 여기서는 이름이 비어 있거나 0세 이상 120세 이하의 나이가 아니라면 LivingPerson 인스턴스가 생성되지 않는다. 이 실패 가능한 초기자는 반환값으로 옵셔널을 생성하기 때문에 사용하려면 옵셔널 바인딩이 필요하다. return nil을 해주는 것을 잘 보자. 

deinit은 인스턴스가 메모리에서 해제될 때 호출되는 소멸자이다. 클래스당 오직 하나만 선언할 수 있고, 파라미터가 없다. 인스턴스에 nil이 할당되면 그 자리에는 아무것도 남지 않기 때문에 소멸자가 자동 호출된다. 수동 호출할 수 있는 게 아니다. 

// 실패 가능한 이니셜라이저이기 때문에 옵셔널 타입으로 선언해야 함)
var thomas: LivingPerson? = LivingPerson(name: "thomas", age: 20)	// LivingPerson
let alvin: LivingPerson? = LivingPerson(name: "alvin", age: 220)	// nil
let carter: LivingPerson? = LivingPerson(name: "", age: 20)	// nil
thomas = nil	// Good bye!

사람 셋을 만들어봤다. nil이 결과로 주어질 수도 있기 때문에 옵셔널 타입으로 선언해야 한다.

당연히 이 인스턴스들을 사용하려면 옵셔널 바인딩으로 풀어서 사용해야 한다.

 

 

(10) 열거형 enum

열거형은 관련된 값으로 이뤄진 그룹을 같은 공통 타입으로 선언한다. 이렇게 하면 타입 안전성을 가질 수 있다고 한다. 열거형은 case 자체가 고유한 값이 된다. 다른 프로그래밍 언어에서는 0, 1, 2, ... 등 다른 값으로 호출이 가능하지만 스위프트는 그냥 case가 값이 된다. 상속이 불가능한 값 타입으로서, 열거형 자체가 그냥 데이터 타입이 된다. 열거형 타입 이름은 대문자로 시작해야 한다.

enum Weekday {
  case mon, tue, wed, thu, fri
  case sat
  case sun
}

var day: Weekday = Weekday.mon
day = .tue	// day가 Weekday 타입이기 때문에 축약 사용 가능
print(day)	// tue

다른 프로그래밍 언어처럼 정수값을 줘서 사용하기도 가능하다. 이 경우에는 enum 선언 시, 열거형 이름 다음에 콜론을 찍고 타입을 명시하면 된다. 사실, 정수값뿐 아니라 Hashable 프로토콜을 따르는 모든 타입이 원시값 타입으로 지정될 수 있다. String 같은 것도 그렇다.

enum Grade: Int {
  case A = 1
  case B = 2
  case C = 3
  case D
}

print(Grade.D.rawValue);	// 4

이렇게 case에 타입을 준 경우에는 해당 타입의 rawValue를 사용할 수 있다. D는 명시적으로 값이 주어지지는 않았지만, 위의 경우들을 살펴봤을 때 4가 될 것이라는 사실을 유추할 수 있다. 사실, 이건 중간값이 개판이어도 D의 위의 값이 뭐였는지에 따라서 값이 결정된다. A = 1, B = 100, C = 2 일 때 D 값이 주어져있지 않다면 rawValue는 3이 된다.

근데 이왕 이렇게 쓸 거면 값을 미리 주든가 해야지 아래와 같이 사용하면 안 된다.

enum Grade: Int {
  case A = 1
  case B
  case C = 2
  case D
}
// 이 경우에는 B가 겹쳐서 안 된다. raw value for enum case is not unique 하면서 에러남

enum Grade: Int {
  case A = 1
  case B
  case C = 3
  case D
}
// 이건 된다. B는 2, D는 4가 된다.

위의 Weekday 예제에서 Weekday.wed.rawValue처럼 사용하려고 하면 에러가 난다. Value of type 'Weekday' has no member 'rawValue' 하고 나오고, did you mean to specify a raw type on the enum declaration? 하면서 프로그램이 죽는다. 이 rawValue는 열거형 선언에서 유일한 값이어야 하며, 중복되면 안 된다.

Grade 같은 경우에는 Int라는 기본 타입을 줬기 때문에 rawValue를 쓰는 게 가능했지만 Weekday는 그렇지 않기 때문에 사용할 수 없다는 점을 알아두는 것이 좋겠다.

 

enum 타입 안에서 함수를 선언하고 사용하는 것도 가능하다.

switch에서 열거형을 사용할 때는, 열거형의 모든 경우가 case로 포함되어야 컴파일 에러가 나지 않는다. 어떤 case를 생략하려면 최소한 default라도 써줘야 한다.

enum Month {
  case dec, jan, feb
  case mar, apr, may
  case jun, jul, aug
  case sep, oct, nov
  
  func printMsg() {
    switch self {
      case .mar, .apr, .may: print("봄")
      case .jun, .jul, .aug: print("여름")
      case .sep, .oct, .nov: print("가을")
      case .dec, .jan, .feb: print("겨울")
    }
  }
}

Month.nov.printMsg();	// 가을

 

 

References

 

 

728x90
반응형
0 Comments
댓글쓰기 폼