Swift에선 프로토콜 지향 프로그래밍이 매우 중요하게 여겨진다. 이번 포스트에선 프로토콜에 대해 공부한 내용을 적어보도록 하겠다.
Protocol이란?
프로토콜은 특정 역할을 하기 위한 메소드, 프로퍼티, 기타 요구사항 등의 청사진(설계도) 를 의미한다. 구조체, 클래스, 열거형은 정의된 프로토콜을 채택해서 특정 기능을 실행하기 위한 프로토콜의 요구사항을 직접 구현할 수 있다. 프로토콜의 요구사항을 모두 따르는 타입은 해당 프로토콜을 준수한다고 표현한다. 프로토콜은 설계도 역할을 할 뿐이므로, 스스로 기능을 구현하진 않는다.
Protocol 정의 & 채택하기
Swift 에서 프로토콜은 protocol 키워드를 사용하여 정의할 수 있다.
protocol 프로토콜 이름 {
프로토콜 요구사항
}
클래스, 구조체, 열거형 등의 타입에서는 타입 이름 옆에 콜론(:) 과 프로토콜 이름을 나열해 프로토콜을 채택할 수 있다. 만약 여러 개의 프로토콜을 채택하려면 쉼표(,)로 구분하게 된다.
protocol SomeProtocol {
...
}
protocol AProtocol {
...
}
class SomeClass : SomeProtocol, AProtocol {
...
}
struct SomeStruct : SomeProtocol, AProtocol {
}
enum SomeEnum : SomeProtocol, AProtocol {
}
만약 클래스가 다른 클래스를 상속 받는다면, 슈퍼 클래스의 이름을 제일 좌측에 선언하고, 그 다음 프로토콜들을 나열한다.
class SuperClass {
}
class SomeClass :SuperClass, SomeProtocol, AProtocol {
...
}
프로토콜 설계도 작성하기
우선 요약하자면, 프로토콜은 프로퍼티, 메소드, 이니셜라이저를 요구할 수 있으며, 자신을 채택한 타입은 요구사항들을 구현해야 한다.
프로퍼티 요구
프로토콜은 자신을 채택한 타입이 어떤 프로퍼티를 구현해야 하는지 요구할 수 있다. 그러나 프로토콜은 그 프로퍼티가 연산 프로퍼티인지 저장 프로퍼티인지는 신경쓰지 않는다. 프로퍼티 이름과 타입만 맞도록 구현해주면 되는데, 해당 프로퍼티가 읽기 전용인지, 읽고 쓰기가 모두 가능한지는 정의해줘야 한다. 해당 속성은 프로퍼티 타입 옆에 {get set} 으로 지정해줄 수 있다. 프로토콜에서 프로퍼티는 무조건 var 키워드를 사용해 선언하며, 구현체에선 var 과 let 모두 사용할 수 있다.
protocol Student {
var studentNumber:Int {get} //읽기가 가능한 학생번호 프로퍼티!
var grade: Int {get set} //읽고 쓰기가 모두 가능한 학년 프로퍼티!
}
만약 프로퍼티가 읽고 쓰기가 모두 가능하다면, 해당 프로퍼티는 상수 프로퍼티나 읽기 전용 프로퍼티로 구현할 수 없다. 위에서 grade 프로퍼티는 읽고 쓰기가 모두 가능한 프로퍼티이므로, 다음과 같은 코드는 컴파일 에러를 일으킨다.
class John : Student {
var studentNumber: Int
let grade: Int //grade 는 읽고 쓰기가 모두 가능한 프로퍼티로 구현해야 하는데, 상수므로 컴파일 에러!
//private(set) var grade: Int -> setter 가 없으므로 컴파일 에러!
init(studentNumber : Int, grade : Int) {
self.studentNumber = studentNumber
self.grade = grade
}
}
정상적으로 프로토콜을 채택한 John 클래스를 보자
class John : Student {
var studentNumber: Int
var grade :Int
init(studentNumber : Int, grade : Int) {
self.studentNumber = studentNumber
self.grade = grade
}
}
Student 프로토콜에서 studentNumber 는 읽기가 가능한 프로퍼티로 요구했다. 그러나 실제 구현부에선 읽기와 쓰기가 모두 가능한 프로퍼티로 작성할 수 있다. 프로토콜을 채택하면 해당 프로퍼티가 요구하는 프로퍼티를 모두 구현해야 하고, 만약 하나라도 빠진다면 컴파일 에러가 발생한다.
만약 타입 프로퍼티를 요구하고 싶다면, static 키워드를 사용하면 된다.
protocol Student {
static var school : String {get}
var studentNumber:Int {get} //읽기가 가능한 학생번호 프로퍼티!
var grade: Int {get set} //읽고 쓰기가 모두 가능한 학년 프로퍼티!
}
class John : Student {
static let school: String = "상원고등학교"
var studentNumber: Int
var grade :Int
init(studentNumber : Int, grade : Int) {
self.studentNumber = studentNumber
self.grade = grade
}
}
프로토콜에선 var 키워드를 사용해 학교 이름을 요구했지만, getter 만 구현하면 되기 때문에 구현부에선 상수인 let 으로 구현하는게 가능하다.
프로토콜의 프로퍼티 요구 정리
1. 프로토콜에서 프로퍼티는 항상 var 키워드를 사용해 정의한다.
2. 프로퍼티의 이름과 종류, 그리고 읽기 전용인지 읽고 쓰기가 모두 가능한지 정의해줘야 한다.
3. 프로토콜에서 읽기 전용 프로퍼티는 구현부에서 읽고 쓰기가 모두 가능한 프로퍼티로 구현할 수 있지만, 읽고 쓰기가 모두 가능한 프로퍼티는 읽기만 가능한 프로퍼티로 구현할 수 없다.
4. 타입 프로퍼티 요구사항은 static 키워드를 사용한다.
프로토콜 메소드 요구
프로토콜은 인스턴스 메소드 또는 타입 메소드를 요구할 수 있다. 프로토콜 정의 부분에서 메소드의 이름, 매개변수, 반환 타입 등을 지정한다. 하지만 Kotlin과 Java 와 같이 실제 구현부인 중괄호 {} 는 제외한다. 타입 메소드를 정의하고 싶으면 프로퍼티와 마찬가지로 static 키워드를 사용한다.
protocol Teacher {
var teacherNumber:Int {get}
var subjectInCharge : String {get set}
//학생을 가르칩니다.
func teachStudent(student: Student)
//인스턴스가 학생인지 확인합니다.
static func isStudent(_ instance : Any) -> Bool
}
Teacher 프로토콜을 정의했다. 함수의 이름, 매개변수, 반환 타입만 지정하고 실제 함수 구현은 프로토콜을 채택한 타입에게 맡긴다.
class Kim : Teacher {
var teacherNumber: Int
var subjectInCharge: String
//Student 프로토콜을 채택한 타입은 모두 매개변수로 받을 수 있다!
func teachStudent(student: Student) {
print("\(student.studentNumber)번 학생에게 \(subjectInCharge) 과목을 가르칩니다.")
}
static func isStudent(_ instance: Any) -> Bool {
if let student = instance as? Student {
print("\(student.studentNumber)는 학생입니다.")
return true
}
return false
}
init(teacherNumber : Int, subjectInCharge : String) {
self.teacherNumber = teacherNumber
self.subjectInCharge = subjectInCharge
}
}
Teacher 프로토콜을 채택한 Kim 클래스를 구현했다. Teacher 프로토콜이 요구하는 다음 프로퍼티와 메소드
- teacherNumber
- subjectInCharge
- func teachStudent(student: Student)
- static func isStudent(_ instance : Any) -> Bool
를 구현했다. 여기서 teachStudent 함수의 매개변수로 Student 타입을 받는 것을 보자. Student 는 일전에 프로토콜로 정의했다. 해당 프로토콜 타입의 인스턴스를 매개변수로 받는다는 것은, 해당 프로토콜을 채택한 모든 타입을 매개변수로 받을 수 있다는 것을 뜻한다.
let kim : Kim = Kim(teacherNumber: 100, subjectInCharge: "수학")
let john = John(studentNumber: 412, grade: 3)
if Kim.isStudent(john) {
kim.teachStudent(student: john)
}
//출력 결과
412는 학생입니다.
412번 학생에게 수학 과목을 가르칩니다.
John 클래스는 Student 프로토콜을 채택한 타입으로, Teacher 프로토콜의 메소드인 teachStudent( student: Student) 메소드에 인자로 넘길 수 있다.
프로토콜 메소드 요구 정리
1. 프로토콜의 메소드는 메소드 이름과 매개변수, 반환 타입을 정의하지만 실제 구현 부분은 프로토콜을 채택할 구현체에게 맡긴다.
2. 타입 메소드는 static 키워드를 사용한다.
3. 프로토콜을 매개변수나 반환 타입, 또는 프로퍼티의 타입으로 지정할 수 있고, 이것의 의미는 해당 프로토콜을 채택한 모든 타입을 매개변수나 반환값으로 사용할 수 있음을 의미한다.
프로토콜 mutating func 요구
메소드가 인스턴스 내부의 값을 변경해야 할 경우가 있다. 값 타입(구조체,열거형) 의 인스턴스 메소드에서는 인스턴스 내부의 값을 변경하고자 할 때는 func 앞에 mutating 키워드를 붙여 메소드에서 인스턴스 내부의 값을 변경한다는 것을 확실히 해준다. 프로토콜이 어떤 타입이든 간에 인스턴스 내부의 값을 변경해야 하는 메소드를 요구하려면 메소드 정의 앞에 mutating 키워드를 명시해야 한다.
protocol SomeProtocol {
mutating func reset()
}
참조 타입인 class 에선 굳이 mutating func 가 아니더라도 인스턴스 내부의 값을 변경할 수 있다. 따라서 만약 위 프로토콜을 클래스 타입이 채택한다면, 굳이 mutating 키워드를 붙이지 않고도 구현이 가능하다.
class SomeClass : SomeProtocol {
var value : Int = 5
func reset() {
value = 0
}
}
하지만 값 타입의 구조체나 열거형에선 무조건 mutating 키워드를 사용해야 한다. mutating 키워드가 없는 메소드는 인스턴스 내부의 값을 변경할 수 없기 때문이다.
struct SomeStruct : SomeProtocol {
var value : Int = 5
mutating func reset() {
value = 0
}
}
이니셜라이저 요구
프로토콜은 특정한 이니셜라이저를 요구할 수 있다. 메소드와 마찬가지로 이니셜라이저의 매개변수를 지정하기만 할 뿐 실제 이니셜라이져 구현은 프토콜을 채택할 타입에 맡긴다.
protocol Named {
var name : String {get}
init(name: String)
}
struct Pet: Named {
var name: String
init(name: String) {
self.name = name
}
}
Pet 구조체는 Named 프로토콜을 채택해 요구 프로퍼티와 요구 이니셜라이져를 구현했다. 구조체는 상속할 수 없기 때문에 이니셜라이져 요구에 대해 크게 신경 쓸 필요가 없지만, 클래스의 경우라면 다르다.
class Human : Named {
var name: String
required init(name: String) {
self.name = name
}
}
클래스 타입에서 프로토콜의 이니셜라이저 요구에 부합하는 이니셜라이저를 구현할 때엔 이니셜라이저가 required 식별자를 붙인 요구 이니셜라이저로 구현해야 한다. 만약 클래스 자체가 final 클래스로 상속할 수 없는 클래스라면 required 식별자를 붙이지 않아도 된다.
final class Human : Named {
var name: String
init(name: String) {
self.name = name
}
}
만약 특정 클래스에 프로토콜이 요구하는 이니셜라이저가 구현되어 있는 상황에서 그 클래스를 상속받은 상황이 있다면 required 와 override 식별자를 모두 붙여 이니셜라이저를 구현해야 한다.
class Human {
var name: String
init(name: String) {
self.name = name
}
}
class HumanSubClass : Human, Named {
required override init(name: String) {
super.init(name: name)
}
}
프로토콜은 또한 실패 가능한 이니셜라이저를 요구할 수도 있다.
protocol FailableProtocol {
var name: Int {get set}
init?(value:String)
}
class NilableClass : FailableProtocol {
var name: Int
required init?(value: String) {
guard let value = Int(value) else {
return nil
}
name = value
}
}
let nilableClass = NilableClass(value: "123a")
print(nilableClass) //nil
하지만 프로토콜이 요구하는 이니셜라이저가 실패 가능 이니셜라이저라 해도, 실제 구현할 때는 일반적인 이니셜라이저로 구현해도 무방하다.
프로토콜 이니셜라이저 요구 정리
1. 프로토콜의 이니셜라이저는 이니셜라이저의 매개변수만 지정하고 실제 구현은 프로토콜을 채택할 타입에 맡긴다.
2. struct 는 상속이 불가능하므로 이니셜라이저를 그냥 구현하면 된다.
3. class 는 상속이 가능하므로, required 키워드를 사용해야 한다. 만약 상속이 불가능한 final 클래스라면 required 키워드를 붙이지 않아도 된다.
4. 프로토콜은 실패 가능한 이니셜라이저를 요구할 수도 있지만, 실제 구현할 때는 굳이 따르지 않아도 된다.
프로토콜의 상속과 클래스 전용 프로토콜
프로토콜은 하나 이상의 프로토콜을 상속받아 기존 프로토콜의 요구사항보다 많은 요구사항을 추가할 수 있다. 프로토콜 상속 문법은 클래스의 상속 문법과 유사하다.
protocol Singer {
func sing()
}
protocol Writer {
func write()
}
protocol SingerSongWriter : Singer, Writer{
func writeAndSing()
}
class Carry : SingerSongWriter {
func writeAndSing() {
print("작사와 노래를 동시에")
}
func sing() {
print("노래 부름")
}
func write() {
print("작사함")
}
}
SingerSongWriter 프로토콜은 Singer 와 Writer 프로토콜을 상속했다. 그래서 SingerSongWriter 프로토콜을 채택하는 타입은 Singer, Writer 프로토콜과 SingerSongWriter 프로토콜이 요구하는 모든 것을 구현해야 한다.
또 프로토콜을 채택할 타입을 오직 class 로만 제한할 수 있다. 클래스 전용 프로토콜로 제한을 주기 위해선 프로토콜의 상속 리스트의 맨 처음에 class 키워드를 붙인다.
//오로지 클래스에서만 채택할 수 있다!
protocol ClassOnlyProtocol : class {
}
'Swift' 카테고리의 다른 글
Swift - 타입 중첩 (0) | 2022.02.21 |
---|---|
Swift - 제네릭 (0) | 2022.02.20 |
Swift - 서브스크립트 (0) | 2022.02.18 |
Swift - Map, Filter, Reduce (1) | 2022.02.17 |
Swift - 옵셔널 체이닝, guard (0) | 2022.02.16 |
Comment