본문 바로가기

학습 노트/Swift (2021)

170 ~ 172. Selector, Keypath, Dynamic Access

Selector (셀렉터)

Selector(셀렉터)는 UIkit에서 method를 지칭하거나, 속성의 getter나 setter를 지칭할 때 사용한다.
'지칭한다.'의 의미는 호출한다는 의미가 아닌, 대상을 가리키는 특별한 인스턴스를 얻는다는 뜻이다.
이러한 인스턴스는 버튼과 메소드를 연결하거나, 제스쳐와 메소드를 연결할 때 사용한다.

Syntax

#selector(methodName)
#selector(getter: propertyName)
#selector(setter: propertyName)
struct Figure {
	let color: UIColor = .blue
	
	func draw() {
		print("draw")
	}
}

let selector = #selector(Figure.draw)
결과

//error


셀렉터는 구조체로 구현된 타입으로, 새로운 인스턴스를 생성할 때 셀렉터 표현식을 사용한다.
또한 메소드를 전달하는 경우 메소드 호출문'method()'이 아닌 메소드 형태를 그대로 전달해야 한다.

셀렉터 자체는 objective-C에서 사용하는 개념이고, swift도 이를 다르고 있다.
셀렉터에 전달하고 있는 draw 메소드는 순수한 swift 메소드이기 때문에 objective-C에서 인식할 수 없다.
이렇게 objective-C에서 인식할 수 있는 메소드를 objectie-C method(오브젝티브C 메소드)라고 부르는데, 메소드 앞에 '@objc' 특성을 추가하는 것으로 해결할 수 있다.

struct Figure {
	let color: UIColor = .blue
	
	@ objc func draw() {
		print("draw")
	}
}

let selector = #selector(Figure.draw)
결과

//error

하지만 에러는 계속 발생한다.
'@objc' 속성은 구조체의 멤버에게는 적용할 수 없다.
클래스, pbjective-C 프로토콜, 클래스 익스텐션에 포함된 멤버에만 적용할 수 있다.
따라서 현재 구현되어있는 구조체를 클래스로 바꿔야만 한다.

class Figure {
	let color: UIColor = .blue
	
	@objc func draw() {
		print("draw")
	}
}

let selector = #selector(Figure.draw)
결과

draw

이제는 draw 인스턴스를 지칭하는 인스턴스가 생성되었다.

class Figure {
	let color: UIColor = .blue
	
	@objc func draw() {
		print("draw")
	}
}

let selector = #selector(Figure.draw)

let color = #selector(getter: Figure.color)
결과

//error

속성을 지칭할 때는 getter와 setter를 구분해 지정할 수 있고,
getter를 사용했을 때에는 상수 속성과 변수 속성을 모두 지칭할 수 있지만,
setter를 사용할 경우 변수 속성만 지칭할 수 있다.

이번엔 Figure 클래스의 color 인스턴스를 지칭하는 셀렉터를 추가하자 다시 에러가 발생한다.

class Figure {
	@objc let color: UIColor = .blue
	
	@objc func draw() {
		print("draw")
	}
}

let selector = #selector(Figure.draw)

let color = #selector(getter: Figure.color)
결과

draw
color

마찬가지로 속성에도 '@objc'특성을 추가하면 에러는 사라진다.

셀렉터는 메소드나 멤버에 접근하거나 호출하지 않는다.
그저 가리킬 뿐이다.
이러한 과정은 컴파일 타임에 일어나며, 컴파일러는 지칭하고 있는 멤버가 실제로 존재하는지 확인한다.

셀럭터 응용하기

class ViewController: UIViewController {
	
	@IBOutlet weak var numberLabel: UILabel!
	
	func reset() {
		numberLabel.text = "0"
	}
	
	func update(_ sender: Any) {
		let rnd = Int.random(in: 1...1000)
		numberLabel.text = "\(rnd)"
	}
	
	lazy var updateBtn: UIButton = {
		let btn = UIButton(type: .system)
		btn.setTitle("Update", for: .normal)
		btn.frame = CGRect(x: 0.0, y: self.view.frame.height - 100, width: view.frame.width, height: 60.0)
		return btn
	}()
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
		view.addSubview(updateBtn)
		
		
	}
}

아래쪽에 있는 버튼을 누르면 랜덤 정수를 화면에 표시한다.

원래는 스토리보드에서 버튼을 추가하고, 버튼과 메소드를 연결하게 되는데,
이렇게 하면 스토리보드에서 자동으로 처리해, 셀렉터를 몰라도 상관없다.
하지만 버튼을 코드로 작성하거나 연결할 메소드를 조건에 따라 선택해야 한다면, 메소드를 셀렉터로 만들고 직접 연결해야 한다.

viewDidLoad 함수에 버튼과 연결할 메소드를 작성한다.

updateBtn.addTarget(target: Any?, action: Selector, for: UIControl.Event)

addTarget 메소드를 사용하는데.
첫 번째 파라미터는 메소드가 구현되어 있는 인스턴스를,
두 번째 파라미터가 Selector,
세 번째는 동작이다.

class ViewController: UIViewController {
	
	@IBOutlet weak var numberLabel: UILabel!
	
	func reset() {
		numberLabel.text = "0"
	}
	
	func update(_ sender: Any) {
		let rnd = Int.random(in: 1...1000)
		numberLabel.text = "\(rnd)"
	}
	
	lazy var updateBtn: UIButton = {
		let btn = UIButton(type: .system)
		btn.setTitle("Update", for: .normal)
		btn.frame = CGRect(x: 0.0, y: self.view.frame.height - 100, width: view.frame.width, height: 60.0)
		return btn
	}()
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
		view.addSubview(updateBtn)
		
		updateBtn.addTarget(self, action: #selector(ViewController.update(_:)), for: .touchUpInside)
	}
}
결과

//error

하지만 이렇게 요구하는 파라미터를 전달해도 에러가 발생한다.

'Argument of '#selector' refers to instance method 'update' that is not exposed to Objective-C'

에러로 update 메소드는 Objective-C에서 인식할 수 없다는 내용이다.
따라서 해당 메소드에 '@objc' 키워드를 추가한다.

class ViewController: UIViewController {
	
	@IBOutlet weak var numberLabel: UILabel!
	
	func reset() {
		numberLabel.text = "0"
	}
	
	@objc func update(_ sender: Any) {
		let rnd = Int.random(in: 1...1000)
		numberLabel.text = "\(rnd)"
	}
	
	lazy var updateBtn: UIButton = {
		let btn = UIButton(type: .system)
		btn.setTitle("Update", for: .normal)
		btn.frame = CGRect(x: 0.0, y: self.view.frame.height - 100, width: view.frame.width, height: 60.0)
		return btn
	}()
	
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
		view.addSubview(updateBtn)
		
		updateBtn.addTarget(self, action: #selector(ViewController.update(_:)), for: .touchUpInside)
	}
}

이제는 에러가 완전히 사라졌다.

updateBtn.addTarget(self, action: #selector(ViewController.update(_:)), for: .touchUpInside)

전달하는 셀럭터 표현식을 보면 'ViewController.update(_:)'이다.
정석대로라면 위와 같이 타입의 이름을 적어줘야 하는데, 타입 안에서 전달하는 경우 추론이 가능하기 때문에

updateBtn.addTarget(self, action: #selector(update(_:)), for: .touchUpInside)

위와 같이 타입 이름을 생략해도 된다.
또한 해당 타입에는 update라는 이름으로 오버 로딩된 함수가 존재하지 않으므로,

updateBtn.addTarget(self, action: #selector(update), for: .touchUpInside)

아규먼트 이름까지 생략해도 된다.


이제는 버튼을 누를 때마다 무작위의 정수가 출력된다.

연습

navigationItem.rightBarButtonItem = UIBarButtonItem(title: String?, style: UIBarButtonItem.Style, target: Any?, action: Selector?)

위의 코드는 상단 우측에 버튼을 추가하는 코드이다.
style 파라미터의 전달 값을 .plain으로 설정하여 reset 메소드를 연결해 보자.

class ViewController: UIViewController {
	
	@IBOutlet weak var numberLabel: UILabel!
	
	@objc func reset() {
		numberLabel.text = "0"
	}
	
	@objc func update(_ sender: Any) {
		let rnd = Int.random(in: 1...1000)
		numberLabel.text = "\(rnd)"
	}
	
	lazy var updateBtn: UIButton = {
		let btn = UIButton(type: .system)
		btn.setTitle("Update", for: .normal)
		btn.frame = CGRect(x: 0.0, y: self.view.frame.height - 100, width: view.frame.width, height: 60.0)
		return btn
		}()
	
	
	override func viewDidLoad() {
		super.viewDidLoad()
		
		view.addSubview(updateBtn)
		
		updateBtn.addTarget(self, action: #selector(update), for: .touchUpInside)
		
		navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(reset))
	}
}

이번에도 마찬가지로 해당하는 타입 내에서 선언하고 있고, 오버 로딩되지 않은 reset 함수가 존재한다.
따라서 seset 함수에 '@objc' 키워드를 추가하고 action에 전달한다.

버튼이 추가되었고, 의도한 대로 작동도 확인되었다.

+

앞서 배운 function type을 파라미터 타입으로 사용하면 번거롭게 셀렉터를 생성할 필요가 없어진다.
그럼에도 셀렉터를 사용하는 이유는 UIKit은 Objective-C로 개발되어 있고, Objective-C는 function type을 제공하지 않는다.
따라서 UIKit이 완전히 swift로 다시 개발되거나, 셀렉터 대신 function type을 받는 메소드가 새로 제공되기 전까지는 셀렉터의 사용이 불가피하다.

 

Keypath (키패스)

Keypath(키패스)또한 swift 이전의 objective-C 에서부터 사용되던 개념이다.
iOS 개발의 key-value 코딩과 key-value observing의 근간을 이루는 개념이다.

let dict = ["A": "Apple", "B": "Banana"]
dict["A"]

딕셔너리는 키와 값을 쌍으로 저장하는 컬렉션이다.
키는 보통 문자열을 사용하고, 이를 통해 값에 접근한다.

키패스도 마찬가지로 문자열을 사용한다.

a.b.c

키패스는 위와 같이 점으로 연결 된 형태이고, 키를 통해 특정 속성에 접근한다.
a 속성에 접근해 b라는 속성에 접근하고 이를 통해 c라는 속성에 접근한다.
따라서 위의 키패스는 c에 접근하는 키패스이다.

기존엔 문자열을 사용했지만 오타로 인한 문제가 많아,
Keypath String Expression(키패스 문자열 표현식)과 Keypath Expression(키패스 표현식)을 사용한다.

Keypath String Expression

class Person: NSObject {
	@objc let name: String = "Jane Doe"
	@objc var age: Int = 0
}

Person 클래스는 NSObject 클래스를 상속받고,
objc 속성인 name과 age가 존재하며,
각각 문자열과 정수의 자료형을 취한다.

키패스를 사용하기 위해선 두 가지 조건이 필요하다.

  1. NSObject 클래스를 상속받아야 한다.
  2. 속성 앞에 '@objc' 속성을 추가해야 한다.

속성이 구조체에 포함되어 있거나 objc 속성이 적용되어 있지 않다면 키패스를 사용할 수 없다.

앞서 말한 key-value 코딩은 문자열 키를 사용해서 속성에 접근하는 기술이다.

class Person: NSObject {
	@objc let name: String = "Jane Doe"
	@objc var age: Int = 0
}

let p = Person()

p.value(forKey: "name")
결과

Jane Doe

이것이 name 속성에 접근하는 가장 간단한 key-value 코딩이다.
하지만 키를 문자열로 전달하고 있기 때문에

class Person: NSObject {
	@objc let name: String = "Jane Doe"
	@objc var age: Int = 0
}

let p = Person()

p.value(forKey: "naem")
결과

//error

이렇게 잘못된 키를 전달하면 크래쉬가 발생한다.
이것이 Keypath String Expression(키패스 문자열 표현식)과 Keypath Expression(키패스 표현식)을 사용하는 이유이다.

Syntax

#keyPath(propertyName)

해당 표현식은 위와 같이 사용한다.
위와 같이 전달하면 컴파일러가 자동으로 문자열 키로 대치해서 전달한다.
방식은 바뀌지 않았지만 컴파일 시점에서 에러를 확인할 수 있다는 큰 장점이 있다.

class Person: NSObject {
	@objc let name: String = "Jane Doe"
	@objc var age: Int = 0
}

let p = Person()

var keypath = #keyPath(Person.name)
p.value(forKey: keypath)
결과

Jane Doe

위와 같이 keypath 변수를 키로 사용하여 접근할 수 있다.

단점


키패스를 사용하는 value(forKey:)와 value(forKeyPath:)는 반환형이 Any?이기 때문에 사용하려면 타입캐스팅이 반드시 필요하다.
하지만 이 과정에서 크래쉬가 발생할 가능성이 있기 때문에 가장 안전한 방법이라고 할 수는 없다.
이를 해결하기 위한 Keypath Type과 Keypath Expression이 있다.

Keypath Type

Keypath String Expression Keypath Expression
String AnyKeyPath, PartialKeyPath, KeyPath,
WritableKeyPath, ReferenceWritableKeyPath
클래스 지원 클래스, 구조체 지원
NSObject 상속, '@objc' 특성 필요 -
컴파일 타임 체크 컴파일 타임 체크, 제네릭 타입,
다양한 방식의 키패스 조합 가능
Syntax

\TypeName.propertyName.propertyName

키패스 표현식은 '\'로 시작한다.
접근할 속성의 타입 이름을 작성하는데 이를 Root Type, Base Type이라고 부른다.
이어서 접근할 속성의 이름을 작성한다. 이때 연결되는 수는 제한이 없다.

struct Person {
	let name: String = "Jane Doe"
	var age: Int = 0
}

let p = Person()

let keypath = \Person.name
let keypathage = \Person.age
결과

KeyPath<Person, String>
WritableKeyPath<Person, Int>

이것이 name 속성에 접근하는 키패스 표현식이다.
첫 번째는 루트 타입, 두 번째는 속성 타입이다.
반환되는 키패스 타입은 제네릭 클래스로 구현되어 있다.
따라서 캐스팅 없이 바로 사용할 수 있다.

이때 변수로 선언된 age는 WriteableKeyPath인 것을 확인할 수 있다.

class AnyKeyPath
class PartialKeyPath<Root> : AnyKeyPath
class KeyPath<Root, Value> : PartialKeyPath<Root>
class WriteableKeyPath<Root, Value> : KeyPath<Root, Value>
class ReferenceWritableKeyPath<Root, Value> : WritableKeyPath<Root, Value>

키패스 타입은 클래스로 구현되어 있고, 모두 같은 상속계층에 존재한다.
AnyKeyPath는 모든 키패스를 타입에 관계없이 저장한다.
주로 파라미터 형식을 키패스로 선언할 때만 사용한다.

PartialKeyPath는 AnyKeyPath를 상속한다.
PartialKeyPath는 루트 타입을 명시적으로 선언한다.
같은 타입에 속한 키패스를 하나의 형식으로 처리하는 경우 사용한다.

KeyPath는 PartialKeyPath를 상속받고, 루트 타입과 속성 타입을 명시적으로 선언한다.
값을 바꿀 수 없는 속성을 가리키는 타입이 KeyPath이다.

WritableKeyPath는 KeyPath를 상속받고, 루트와 속성 타입을 명시적으로 선언한다.
또한, 값을 바꿀 수 있는 속성을 가리킨다.
구조체와 같은 값 형식에서만 사용한다.

ReferenceWritableKeyPath는 WritableKeyPath를 상속받고, 루트와 속성 타입을 명시적으로 선언한다.
또, 값을 바꿀 수 있는 속성을 가리키며,
클래스와 같은 참조 형식에서만 사용한다.

struct Person {
	let name: String = "Jane Doe"
	var age: Int = 0
}

let p = Person()

let keypath = \Person.name
let keypathage = \Person.age

let test = p[keyPath: keypath]
결과

Jane Doe

인스턴스 이름 뒤에 '[]' 사이에 keyPath: 를 입력한 뒤 키패스를 작성하면 속성에 접근할 수 있다.

또, 반환된 값도 Any가 아닌 문자열로 타입 캐스팅 없이 사용할 수 있다.

struct Person {
	let name: String = "Jane Doe"
	var age: Int = 0
}

let p = Person()

let keypath = \Person.name
let keypathage = \Person.age

p[keyPath: keypathage] = 77
결과

//error

원래대로라면 age 인스턴스를 가리키는 키패스는 WritableKeyPath로 값을 변경할 수 있지만,
p 인스턴스가 상수로 선언되어 있기 때문에 변경할 수 없다.
변수로 바꾸면 문제없이 가능하다.

struct Person {
	let name: String = "Jane Doe"
	var age: Int = 0
}

var p = Person()

let keypath = \Person.name
let keypathage = \Person.age

p[keyPath: keypath].count
결과

8

키패스 타입은 타입 캐스팅 없이 원본 그대로를 사용할 수 있다.
따라서 기존에 사용했던 속성과 메소드도 사용 가능하다.

키패스를 확장하는 것도 가능하다.

struct Person {
	let name: String = "Jane Doe"
	var age: Int = 0
}

var p = Person()

let keypath = \Person.name
let keypathage = \Person.age
var keypath2 = \Person.name.count

keypath2 = keypath.appending(path: \String.count)
p[keyPath: keypath2]
결과

8

기존의 키패스를 appending 메소드를 사용해 확장했다.
이 경우 타입을 형식을 추론할 수 있기 때문에

keypath2 = keypath.appending(path: \.count)

이렇게 타입을 생략해도 무관하다.

 


Log

2021.09.17.
블로그 이전으로 인한 글 옮김 및 수정