예거's Bicycle for the mind

[Swift] 구조체(struct)와 클래스(class)의 공통점과 차이점, 클래스 인스턴스의 identity 의 개념 본문

iOS & Swift

[Swift] 구조체(struct)와 클래스(class)의 공통점과 차이점, 클래스 인스턴스의 identity 의 개념

유예거 2021. 10. 28. 05:30

스위프트의 사용자 정의 타입으로는, 구조체(struct)와 클래스(class), 열거형(enum) 등이 있다.

 

이번 글에서는 구조체와 클래스의 공통점과 차이점에 대해 정리하고, 어떤 기준으로 둘 중에 하나를 선택해야 하는지 정리해보자!

 

구조체와 클래스의 공통점과 차이점

- 타입/인스턴스 프로퍼티를 가질 수 있다.

- 타입/인스턴스 메서드를 가질 수 있다.

- 서브스크립트 문법(subscript syntax)을 사용하여 값에 접근할 수 있다.

- 초기화 상태(initial state)를 만들기 위한 이니셜라이저를 정의할 수 있다.

- 기능적 확장이 가능하다.

- 프로토콜을 준수할 수 있다.

 

여기서, 클래스는 구조체가 가지고 있지 않은 별도의 능력이 있다.

 

- 클래스의 단일 상속이 가능하다. (구조체는 상속 불가)

- 타입 캐스팅(Type casting)을 사용하면 런타임 동안 클래스 인스턴스의 타입을 확인할 수 있고, 인스턴스의 타입을 슈퍼클래스 또는 서브클래스 타입처럼 다룰 수 있다.

- 디이니셜라이저(Deinitializer)를 사용하면, 클래스의 인스턴스에 할당된 리소스를 해제할 수 있다.

- 참조 카운팅(Reference counting)은 클래스 인스턴스에 대한 하나 이상의 참조를 허용한다.

 

어떤 기준으로 구조체와 클래스를 선택해야 할까?

애플 공식 문서에서는 아래와 같은 가이드라인을 제시했다.

 

- 기본적으로 구조체를 사용해라. (Use structures by default.)

- Objective-C 하고 상호운영성(interoperability)이 필요할 때 클래스를 사용해라.

- 데이터의 identity 를 다뤄야 할 필요가 있다면 클래스를 사용해라.

- 기능 구현을 공유하고 싶다면, (클래스 간의 상속이 아니라) 구조체프로토콜과 함께 사용해라.

 

여기서 identity 라는 개념이 굉장히 중요하다.

 

스위프트를 조금이라도 공부해봤다면, 구조체는 값 타입, 그리고 클래스는 참조 타입이란 걸 알고 있을 것이다.

 

프로퍼티의 값이 모두 동일한 2개의 클래스 인스턴스가 있다고 생각해보자.

이 둘은 identity operator (===)로 판단해보면 false 라는 결과가 나올 것이다.

 

이 부분은 예시 코드와 함께 살펴보자!

 

클래스의 인스턴스와 identity 의 개념

// 2개의 인스턴스 프로퍼티를 갖는 클래스 Person 을 만들어보자.
class Person {
    var name: String
    var age: Int
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

// 2개의 인스턴스 생성. 프로퍼티는 동일하지만, 인스턴스의 메모리 주소가 다르다.
let jager = Person(name: "예거", age: 30)
let mimic = Person(name: "예거", age: 30)

// jager 와 mimic 의 identity 비교
print(jager === mimic) // false (다른 identity)

 

구조체의 인스턴스를 만들고 그걸 var 또는 let 에 할당해준다면, 그건 인스턴스의 실체를 메모리에 저장하는 것이라고 볼 수 있다. (값 타입)

 

반면, 클래스의 인스턴스를 만들어 var 또는 let 에 할당해주는 것은, 인스턴스의 실체는 메모리 어딘가에 저장되고, 대신에 메모리 주소가 저장된다. 그 인스턴스의 실체를 가리키는 주소값이 할당되는 것이다.

 

위 예시에서 let 으로 선언한 인스턴스 jager, mimic 의 경우, 메모리 주소값이 할당되어 있다고 할 수 있다.

그 주소가 identity 인 것이고, identity operator (===) 를 통해 그 주소값이 동일한지 확인할 수 있다.

 

// 인스턴스 jager 의 주소값을 그대로 mimic 에 할당해준다면?
let jager = Person(name: "예거", age: 30)
let mimic = jager

// jager 와 mimic 의 identity 비교
print(jager === mimic) // true (같은 identity)

 

위 예시 처럼, 인스턴스 jager주소값을 그대로 mimic 에 할당해준다면, === 로 비교했을 때 true 가 나온다.

두 객체는 같은 주소값을 저장하고 있으니, 같은 identity 라고 할 수 있다.

 

// 하나의 인스턴스를 계속 참조할 수 있다. (참조 카운팅 == 3)
let jager = Person(name: "예거", age: 30)
let mimic = jager
let copycat = mimic

// jager, mimic, copycat 의 identity 비교
print(jager === mimic) // true (같은 identity)
print(jager === copycat) // true (같은 identity)
print(mimic === copycat) // true (같은 identity)

 

하나의 인스턴스에 대한 참조는 계속 늘릴 수 있다.

참조하는 객체가 하나 늘어날 때마다, 참조 카운팅(Reference counting) 또한 하나씩 늘어난다.

 

위 예시에서는 jager, mimic, copycat 이 모두 동일한 identity 를 갖고 있으므로, 저 인스턴스는 총 3개의 참조 카운팅을 가지고 있다.

 

만약 앱 전체에서 하나의 클래스 인스턴스를 공유해야 한다면, 그 인스턴스를 전역으로(globally) 만들어서, 필요한 부분에서 그 인스턴스를 참조하게 만들 수도 있다.

이 개념이 싱글톤(Singleton)인데, 조만간 별도의 글로 정리해보겠다!

 

자 어쨌든, 클래스의 인스턴스는 동일한 메모리 주소(identity)를 참조하는 객체 숫자를 늘릴 수도, 줄일 수도 있다.

그러다 참조 카운팅이 0 이 된다면, 스위프트의 ARC(Automatic Reference Counting) 기능에 의해, 그 인스턴스는 메모리에서 해제될 것이다.

 

여기서 꼭 체크하고 넘어가면 좋을 개념이 있다.

 

상수(let)로 선언된 클래스 인스턴스의 프로퍼티 값을 변경할 수 있는 이유는?

// 상수(let)로 선언된 jager 와 mimic
let jager = Person(name: "예거", age: 30)
let mimic = jager

mimic.name = "미믹"
print(mimic.name) // 미믹
print(jager.name) // 미믹

 

우리는 let 은 변경할 수 없는 값인 상수. var 는 변경할 수 있는 변수라고 배웠다.

 

근데 위 예시를 보면, jager 와 mimic 모두 let 으로 선언했는데, mimic 인스턴스의 name 프로퍼티를 "미믹" 으로 변경할 수 있다. 둘은 같은 인스턴스를 참조하고 있기에, name 프로퍼티는 둘 다 "미믹" 으로 출력된다.

 

이런 일이 가능한 이유는, 위에서 언급했듯이, 클래스 인스턴스를 let, var 에 할당하면 인스턴스의 실체가 아니라 메모리 주소만 저장되기 때문이다.

 

우리는 메모리 주소값을 바꾸려고 시도한 게 아니라, 그 주소를 찾아가면 나오는 인스턴스의 name 프로퍼티를 변경한 것이다.

그리고 그 name 프로퍼티는 변수(var) 였기에 변경이 가능했던 것이다. 프로퍼티가 상수(let)였다면, 변경할 수 없었을 것이다.

 

Equatable 프로토콜을 통해 클래스 인스턴스들의 프로퍼티 값이 동일한지 확인할 수 있다.

혹시 클래스 인스턴스를 비교할 때, == 연산자는 사용할 수 없을까? 바로 시도해보자!

 

 

== 연산자를 바로 사용할 수 없다는 컴파일 에러가 뜬다.

 

하지만 Equatable 프로토콜을 사용하면, == 연산자를 커스터마이징해서 인스턴스 간의 비교가 가능하다는 사실!

 

// Person 클래스에 Equatable 프로토콜 적용
class Person: Equatable {
    // Equatable 프로토콜을 준수하기 위한 requirement
    static func == (lhs: Person, rhs: Person) -> Bool {
        (lhs.name == rhs.name) && (lhs.age == rhs.age)
    }
    
    var name: String
    var age: Int
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

let jager = Person(name: "예거", age: 30)
let mimic = jager
let copycat = Person(name: "예거", age: 30)

// == 와 === 를 함께 쓰며 비교해보자.
print(jager == mimic) // true. jager 와 mimic 의 프로퍼티는 동일하다.
print(jager === mimic) // true. 둘의 identity 또한 동일하다.
print(mimic == copycat) // true. mimic 과 copycat 의 프로퍼티는 동일하다.
print(mimic === copycat) // false. 둘의 identity 는 다르다. (다른 주소값)
print(mimic != copycat) // false. == 연산자를 정의했으므로, 그 반대를 뜻하는 != 연산자도 사용 가능하다.

 

Equatable 프로토콜을 준수하기 위해서, 타입 메서드의 형태로, 연산자 == 를 반드시 커스터마이징해줘야 한다.

 

이때, lhs 는 left hand side, rhs 는 right hand side 를 말한다. 쉽게 말하면 왼쪽, 오른쪽이다.

굳이 저 단어를 유지할 필요도 없다. left, right 로 바꿔도 구현에는 문제가 없다. ㅎㅎ

 

어쨌든 나는, 클래스가 가진 2개의 인스턴스 프로퍼티(name, age)가 동일하면, == 연산자로 비교했을 때 true 값을 반환하도록 만들었다.

name 프로퍼티가 똑같더라도, name 이 조금이라도 다르면 false 가 나오도록 && 연산자로 두 조건을 묶어줬다.

 

근데 클래스에 Equatable 프로토콜을 붙일 일이 있긴 할까?

라는 생각이 들기도 했지만, === 와 비슷하게 생겼으니까 그 차이점을 다시 한번 공부할 겸 만들어봤다. 😊

 

참고 링크

[Swift 공식 문서] Structures and Classes

[Apple Developer - Article] Choosing Between Structures and Classes

 

Comments