💎 Swift의 다형성과 Protocol
✏️ 다형성이란
- 하나의 객체에 여러 가지 타입을 대입할 수 있다는 것
class Animal {
func eat() {
fatalError("Should be implemented")
}
}
class Horse: Animal {
override func eat() {
print("말이 당근을 먹습니다.")
}
}
class Cat: Animal {
override func eat() {
print("고양이가 츄르를 먹습니다.")
}
}
var animal: Animal = Horse()
animal.eat()
animal = Cat()
animal.eat()
animal
지역변수는 컴파일 타임에 Animal
타입에 의존하고 있다.
- 하지만
Animal
을 상속하는 Horse
, Cat
타입(구체 타입)의 인스턴스를 주입할 수 있다.
- 즉, 컴파일 타임에서의 의존성 (
Animal
)과 런타임에서의 의존성 (Horse
, Cat
)이 다르다.
- 또한, 컴파일러가
Animal
타입에 eat()
메서드가 있는 것을 알고 있기 때문에, Animal
을 상속하는 어떤 타입이 주입되더라도 eat()
메서드를 호출할 수 있다.
- 똑같은 메서드를 호출하지만, 결과는 런타임에서 어떤 타입이 주입되는가에 따라 달라진다.
- 이것이 객체지향의 큰 특징중 하나인 다형성(Polymorphism)이다.
✏️ Protocol
- 다형성을 구현하는 또 다른 방법은 바로 스위프트의 프로토콜(Protocol)이다.
- 프로토콜은 프로토콜을 채택하는 타입들이 공통적으로 구현해야 할 기능들을 정의한 설계도 같은것이다.
protocol Animal {
var name: String { get set }
func eat()
}
struct Horse: Animal {
var name: String
func eat() {
print("\(name)가 당근을 먹습니다.")
}
}
struct Cat: Animal {
var name: String
func eat() {
print("\(name)가 츄르를 먹습니다.")
}
}
var animal: Animal = Horse(name: "적토마")
animal.eat()
animal = Cat(name: "개냥이")
animal.eat()
Horse
와 Cat
은 Animal
을 채택 하고 있기 때문에 name
프로퍼티와 eat()
메서드를 반드시 가지고 있어야 한다.
- 프로토콜은 어떤 타입이든 채택할 수 있으므로, 프로토콜은 값타입에서도 다형성을 구현할 수 있다.
- 또, 하나의 타입이 여러개의 프로토콜을 채택할 수 있다.
- 프로토콜은 상속이라는 고질적인 문제점을 해결할 수 있다. 상속은 객체의 특징들이 다양해질수록 자식 클래스가 계속해서 늘어나는 문제가 있지만, 프로토콜은 합성이라는 방법을 통해 이를 간단하게 해결할 수 있다.
✏️ Associated Type: 연관 타입
- Associated Type은 프로토콜에서 제네릭과 같이 하나의 타입 매개변수로 사용할 수 있는 기능이다.
- 우리가 자주 사용하는
Array
는 Collection
프로토콜을 채택하는 타입이다.
public protocol Collection: Sequence {
associatedtype Element
associatedtype Index: Comparable
var startIndex: Self.Index { get }
var endIndex: Self.Index { get }
public var first: Self.Element? { get }
}
- 그리고 이
Collection
은 Sequence
를 채택하고 있다. 이렇게 여러 프로토콜을 채택할 수 있다는 걸 확인해볼 수 있다.
- 무튼, 여기서
associatedtype
은 Generic과 마찬가지로 타입 파라미터의 역할을 한다. 그리고 이 associatedtype
은 이 프로토콜을 구현하는 타입에 의해 결정된다.
- Element는
associatedtype
을 통해 선언된 임의의 타입으로 구체적으로 어떤 타입인지 모르지만, 이 프로토콜을 채택한 타입이 구현할 때 이 값을 구현한다. 그리고 그 때 associatedtype
이 어떤 타입인지 결정된다.
- 스위프트의
Collection
은 컬렉션에 담을 원소인 Element
와 그 Element
의 위치를 나타내는 Index를 연관 타입으로 선언했기 때문에, Int
, String
, 기타 struct등 스위프트의 모든 타입을 담는 Array를 만들 수 있는 것이다.
var animals: [Animal] = [
Cat(name: "길냥이"),
Horse(name: "천리마")
]
animals
의 Element
는 Animal
이라는 타입으로 대체되고, Index
는 Int
가 된다.
- 그럼 이 연관 타입을 사용해보자.
protocol Animal {
associatedtype Feed
var feed: Feed { get }
var name: String { get set }
func eat()
}
Animal
프로토콜에 Feed
라는 연관 타입을 추가했다. 이 Feed
는 다른 어떤 타입으로도 대체가 가능하다.
struct Carrot {}
struct Churu {}
Feed
는 Carrot
이 될 수도 있고, Churu
가 될 수도 있다. 이 두가지 뿐만 아니라 어떤 타입이든 이 Feed
로 선언될 수 있다.
struct Horse: Animal {
typealias Feed = Carrot
var feed: Feed = Carrot()
var name: String
func eat() {
print("\(name)가 \(feed)을 먹습니다.")
}
}
struct Cat: Animal {
typealias Feed = Churu
var feed: Feed = Churu()
var name: String
func eat() {
print("\(name)가 \(feed)를 먹습니다.")
}
}
- 이렇게 각각의
feed
프로퍼티에 알맞은 사료를 넣어주었다.
- 하지만, 이코드의 문제는
feed
로 아무 타입으로 대체가 가능하다는 것이다.
- 즉, 타이어나 컴퓨터 타입을 만들어
feed
를 이 타입으로 대체해버릴 수 있는 것이다. 타이어를 씹는 말이라..
- 또
Feed
라는 타입이 어떤 프로퍼티와 메서드를 가질 지 모르기 때문에, 어떤 정보를 String
으로 표시할 수 있는지도 알 수 없다. eat()
메서드에서 \(feed)
가 무엇을 프린트할지 우리도 모르고 컴파일러도 모른다.
- 이를 한번 해결해보자.
protocol Eatable {
var name: String { get }
var expiration: Date { get }
}
protocol Animal {
associatedtype Feed: Eatable
var feed: Feed { get }
var name: String { get set }
func eat()
}
struct Carrot: Eatable {
var name: String = "드럽게 비싼 무농약 유기농 당근"
var expiration: Date = Date()
}
struct Churu: Eatable {
var name: String = "고양이 눈돌아가는 대존맛 츄르"
var expiration: Date = Date()
}
struct Horse: Animal {
typealias Feed = Carrot
var feed: Feed = Carrot()
var name: String
func eat() {
print("\(name)가 \(feed.name)을 먹습니다.")
}
}
struct Cat: Animal {
typealias Feed = Churu
var feed: Feed = Churu()
var name: String
func eat() {
print("\(name)가 \(feed.name)를 먹습니다.")
}
}
- 이제
associatedtype
인 Feed
는 Eatable
을 채택한다. 이 말은 이 연관 타입은 Eatable
을 채택한 타입만 대체될 수 있다는 것이다.
- 타이어는
Eatable
을 채택하지 않기 때문에 더이상 들어올 수 없다.
- 때문에,
feed
프로퍼티는 컴파일러가 name
과 expiration
을 프로퍼티로 가지고 있다는 것을 알 수 있고, eat()
메서드에서 feed.name
으로 접근이 가능해진다.
✏️ associatedtype이 초래한 문제
- 이제 말이 타이어를 씹어먹거나 컴파일러와 우리도 모르는
\(feed)
프린트 문제를 해결했다.
- 하지만 이로인해 새로운 문제가 발생하는데..
- 사실, 이 문제는 이전에
Animal
프로토콜에 Feed
라는 연관 타입을 넣는 순간부터 생긴 문제이다.
- 이전 글에서도 말했듯, 연관 타입은 프로토콜을 구현하는 타입에 의해 결정된다. (매우 중요)
typealias Feed = Carrot
- 다음과 같이
typealias
를 이용해 Feed
가 무슨 타입인지 컴파일러에게 알려주는 과정이 필요하다.
var animal: Animal = Horse(name: "적토마")
- 하지만,
animal
은 Animal
타입이다. 이는 프로토콜에 선언된 feed
, name
프로퍼티와 eat()
메서드에 접근이 가능한 변수라는 뜻이다.
- 여기서 문제가 생기는 것이다. 컴파일러는
Animal
프로토콜을 타입처럼 사용한다. 그럼 feed
프로퍼티에 접근해야 하는데.. 이 Animal
은 프로토콜이고.. 그럼 feed
의 구체 타입이 뭔지 전혀 모르네? 컴파일러는 구체 타입으로 뭔가 들어올 것이라는 확실한 명분이 필요한 것이다.
✏️ some
- 그래서 swift 5.1에서 some 키워드가 등장하였다.
- 이 some키워드는 구체적인 타입 정보를 숨겨준다. 예를 들어 어떤 구체타입인지.. 어떤 연관 타입을 가지고 있는지.. 등등
var animal: some Animal = Horse(name: "적토마")
- 다음과 같이 타입 어노테이션 앞에 써서 사용한다.
- 이
some
키워드는 구체적인 타입 정보를 숨기는 대신, Animal
프로토콜을 구현하는 단 하나의 구체 타입이라는 것을 보장해준다.
- 여기서 단 하나라는 것이 중요한데, 위 예시처럼 하나의 구체 타입으로 선언된 이후에는 이 타입이라는 것을 보장해야 하기 때문에 다른 타입의 인스턴스를 넣어줄 수 없다.
var animal: some Animal = Horse(name: "적토마")
animal = Cat(name: "개냥이")
- 다음과 같이 고양이를 넣으려고 하면 에러가 난다. 하나의 타입임을 보장하기 때문이다.
✏️ any
- 하지만 이걸론 부족하다.. 그래서 swift 5.7에서 any 키워드가 등장하였다.
- 런타임시에 여러가지 구체타입이 프로토콜 타입에 들어가야 할 때 사용하는 키워드이다.
var animals: [any Animal] = [
Cat(name: "길냥이"),
Horse(name: "천리마")
]
for animal in animals {
animal.eat()
}
- 사실, 연관 타입을 사용하면서 any 키워드가 없다면, 그 연관 타입의 크기가 모두 다르기 때문에 배열에 넣어줄 수 없는 문제도 존재한다.
- 배열은 RendomAccessCollection 프로토콜을 채택하고 있는데, 이는 임의의 인덱스가 어느 위치에 있던 그 접근을 O(1)의 시간복잡도로 수행하도록 하는 프로토콜이다. 이런 접근이 가능한 이유는 배열의 내부 엘리먼트가 모두 같은 크기임을 보장하기 때문이다.
- 하지만 연관 타입을 사용해버리면 이 연관타입의 크기가 구체타입에서 어떻게 구현되는가에 따라 달라지기 때문에 문제가 생기는 것이다.
- 하지만 이 any키워드를 사용하면 이 구체타입을 any로 한번 감싼다. 쉽게말해 구체타입을 똑같은 크기의 박스에 담아 이를 런타임 단에서 열어보며 확인한다.
- 이렇게 any를 사용하여 런타임시에 여러가지 구체타입을 넣어줄 수 있다.