[Swift] Swift에서 상속(Inheritance)과 프로토콜(Protocol)에 대한 고찰
⛔️ Swift에서 상속과 프로토콜의 활용에 대한 개인적인 생각을 적은 글입니다. 그렇기 때문에 가볍게 읽어주셨으면 좋겠습니다.
틀린내용에 대한 피드백은 메일로 보내주시면 감사하겠습니다🙏🏻
1️⃣ Swift에서 상속
- 상속은 OOP(객체지향프로그래밍)의 4대요소중 하나로 코드중복을 줄여주는데 큰역할을 합니다.
다음의 이미지를 보면 알 수 있듯이 UiKit의 클래스들은 거의 상속으로 이루어져 있습니다. 다음과 같이 뷰컨트롤러를 만들기위해 UIViewController를 상속해야 합니다.
class MainViewController: UIViewController { ... }
테이블뷰의 셀을 만들기위해 UITableViewCell을 상속받아 구현해야 합니다. 더나아가 UITableViewCell은 UIView를 상속받아 만들어진 클래스입니다.
class TableViewCell: UITableViewCell { ... }
이처럼 UIKit에서 상속은 빼놓을 수 없는 존재입니다. 그러면 상속의 사용을 지향해야할까요?
2️⃣ Swift에서 상속의 단점
(1) 상속을 위해 캡슐화(Encapsulation)가 깨질 수 밖에 없다
상속의 장점은 오버라이딩(overriding)을 통해 다형성(Polymorphism)을 구현할 수 있다는 것 입니다.
하지만 상위클래스에서 오버라이딩을 하는 메서드의 경우 Java언어에서는 protected 키워드가 있어 자식클래스만 접근할 수 있어 그나마 외부클래스에서의 접근에서는 캡슐화를 유지할 수 있습니다.
반면 Swift언어에는 protected키워드가 없습니다. 즉,
(2) 리스코프 치환 원칙을 위반할 가능성이 있다
상위클래스의 메서드를 잘못 오버라이딩을 하게 되면 SOLID의 5대원칙중 하나인 리스코프 치환 원칙(Liskov Subsitution Principle)을 위반할 가능성이 큽니다.
자신이 직접 부모클래스를 구현하지 않은 이상 상속받을 부모클래스의 내부구현을 알기 힘듭니다.
다음의 코드예시는 storage에 새로운string값을 부모클래스에서 이미 담았지만 자식클래스에서 추가로 한번 더담아 총 두번 담도록 구현이 되었습니다. 극단적인 예시이기는 하나 이처럼
class ParentClass {
var storage: [String] = []
func addString(newString: String) {
storage.append(newString)
}
}
class ChildClass: ParentClass {
override func addString(newString: String) {
super.addString(newString: newString)
storage.append(newString)
}
}
상위클래스타입변수에 자식클래스인스턴스를 할당할 수 있지만 메서드호출은 자식클래스의 메서드를 우선순위로 호출된다는점에서 리스코프치환원칙을 지키는 것은 중요합니다.
private func test() {
let sampleClass:ParentClass = ChildClass()
sampleClass.addString(newString: "aa")
print(sampleClass.storage) // 출력: ["aa", "aa"]
}
(3) 상위클래스의 구현에 따라 하위클래스의 동작이 달라질 수 있다
당연한 이야기이지만 상위클래스가 변경되면 하위클래스는 직접적으로 영향을 받게 됩니다.
(4) 결합도증가로 유연성과 확장성이 떨어진다
위의 2,3번 문제점을 생각한다면 클래스간 결합도가 높아진다는 것을 느낄 수 있을 것입니다. 이로인해 유연성과 확장성을 증가시키기위해 사용한 상속이지만, 상속의 깊이가 깊어질수록 오히려 코드의 유연성과 확장성이 떨어지게 됩니다.
(5) 코드를 중복해서 사용할 가능성이 있다
상속의 깊이가 깊어질수록 상위클래스의 추상화의 정도가 커지게 됩니다. 다음의 코드예시는 setImage()메서드를 상속하는 과정에서 최하위클래스에서 네트워크요청코드를 중복해서 사용해버린 상황입니다. 극단적인 예시이지만 네트워크요청과 같은 코드가 중복된다면 비용적인 측면에서 치명적일 것 입니다.
struct NetworkData: Decodable {
var summerTitle: String
var springTitle: String
}
class NetworkManager {
func load(completion: @escaping(NetworkData) -> ()) {
/* 생략 */
}
}
// 부모클래스
class ParentClass {
var mainTitle:String = ""
var data: NetworkData?
let networkManager = NetworkManager()
func setImage() {
networkManager.load { data in
self.data = data
self.mainTitle = data.springTitle // 봄타이틀 지정
}
}
// 자식클래스
class ChildClass: ParentClass {
override func setImage() {
super.setImage()
guard let data = data else { return }
mainTitle = data.summerTitle // 여름타이틀로 변경
}
}
// 자식의 자식클래스
class ChildChildClass: ChildClass {
// 네트워크요청하는 코드를 중복해서 다시사용하는 실수
override func setImage() {
super.setImage()
let networkManager = NetworkManager()
networkManager.load { data in
self.mainTitle = data.springTitle // 봄타이틀로 다시 변경
}
}
}
(6) SOLID원칙중 ISP를 위반할 가능성이 있다
상속의 깊이가 깊어져 상위클래스의 추상화가 커지면 SOLID원칙중 ISP(인터페이스 분리의 원칙)을 위반할 가능성이 커집니다. 상위클래스들을 자신이 모두 구현하고 관리하지 않는 이상 어떠한 메서드가 있는지 정확히 알기 힘듭니다. 상속의 깊이가 깊어질수록 더 힘들어질 것입니다. 이는 상속을 하는과정에서 하위클래스가 필요없는 기능을 가지고 있게될 수도 있습니다.
3️⃣ 상속은 이럴때만 사용하자
(1) 명확히 is-a 관계일때만 사용하자
(2) 상속을 위한 클래스로 구현하자
(3) 자신이 잘알고 관리하는 클래스만 상속하자
4️⃣ 상속대신 Protocol 사용하기(with 컴포지션패턴)
(1) 프로토콜의 확장기능 이용하기
프로토콜의 확장기능 이용하면 미리 코드를 구현하여 사용할 수 있습니다. 또한 해당프로토콜을 준수한 클래스에서 재정의도 가능합니다. Java언어에서 추상클래스(abstract)와 비슷해보이지만 다중상속이 가능하다는 점에서 다릅니다.
이것을 Mixin, Traits패턴이라고 한다는데 다음의 블로그글을 참고하시면 될 것 같습니다.
👉🏻 [김종권의 iOS 앱 개발 알아가기] Mixin 패턴(mix-in), Traits 패턴
protocol Animal {
func bark()
func bite()
}
extension Animal {
func bark() {
print("bark!")
}
func bite() {
print("bite!")
}
}
class Dog: Animal {
func bark() {
print("멍멍!")
}
}
let dog = Dog()
dog.bark() // "멍멍!"
dog.bite() // "bite!"
다음과 같이 응용하여 사용할 수 있습니다. ( 참고사이트[클릭] )
Cell(셀)의 키를 매번 하드코딩할 필요없이 관리할 수 있습니다.
class SampleCell: UITableViewCell {
static let reuseIdentifier = "Cell"
}
protocol ReuseIdentifying {
static var reuseIdentifier: String { get }
}
extension ReuseIdentifying {
static var reuseIdentifier: String {
return String(describing: self)
}
}
class SampleCell: UITableViewCell, ReuseIdentifying {
...
}
(2) SOLID원칙중 ISP를 준수하기가 용이하다
클래스가 프로토콜을 채택하는 방식이고 다중상속이 가능하기 때문에 SOLID원칙중 ISP(인터페이스분리원칙)를 준수하기가 용이합니다. 대신에 프로그래머가 신경을 써서 설계할 필요가 있습니다.
protocol Animal {
func bark()
func bite()
func fly()
}
위의 프로토콜을 아래와 같이 기능에 따라 분리시키는 것이 좋습니다.
protocol Barkable {
func bark()
}
protocol Bitable {
func bite()
}
protocol Flydable {
func fly()
}
(3) 클래스간 결합도를 낮춰줘 유연성이 높아진다
다음처럼 프로토콜만 준수한다면 어떤 클래스든지 유연하게 받아 사용할 수 있습니다. 이러한 특성을 이용하여 상속대신에 컴포지션 패턴으로 구현하여 사용할 수 있습니다.
protocol Engine {
func start()
}
class Car {
private let engine: Engine
init(engine: Engine) {
self.engine = engine
}
func move() {
engine.start()
}
}
class SpecialEngine: Engine {
func start() {
}
}
class DragonEngine: Engine {
func start() {
}
}
let car1 = Car(engine: SpecialEngine())
let car2 = Car(engine: DragonEngine())
(4) UI요소에도 컴포지션패턴을 적용하여 사용이 가능하다
위의 3번에서 UIKit의 클래스를 상속받지않는 클래스들을 손쉽게 컴포지션패턴으로 구현이 가능했습니다. 하지만 다음과 같은 상황이 있을 수 있습니다.
웹사이트를 만드는데 노란색검색바(YellowSearchBar)를 재사용하여 웹사이트에 적용할 계획이다.(UISearchBar상속필요)
더나아가 이 웹사이트는 다양한 검색바(SearchBar)로 교체할 수 있게 구현하고 싶다.(protocol필요)
먼저 다음과 같이 단순하게 SearchBar프로토콜을 만들어 사용한다면 레이아웃 설정 기능을 사용할 때 에러가 발생할 것 입니다.
class WebSite: UIViewController {
private var searchBar: SearchBar!
init(searchBar: SearchBar) {
super.init(nibName: nil, bundle: nil)
self.searchBar = searchBar
layout()
}
...
private func layout() {
self.view.addSubview(searchBar) // 이곳에서 에러가난다
searchBar.translatesAutoresizingMaskIntoConstraints = false
/* searchBar 레이아웃 적용 코드 생략 */
}
}
다행히 프로토콜은
protocol SearchBar: UISearchBar {
func search()
}
다음과 같이 UISearchBar를 상속받는 클래스에서만 채택이 가능해집니다.
class YellowSearchBar: UISearchBar, SearchBar {
init() {
super.init(frame: CGRect.zero)
self.backgroundColor = .yellow
}
...
func search() {
print("search!")
}
}
이런식으로 프로토콜을 사용하면 UI요소에도 유연성을 가진 컴포지션패턴으로 구현할 수 있게 됩니다.
이제 상속의 대부분을 프로토콜로 대체가 가능할 것 같습니다.
(5) weak(unknown)키워드 사용가능(delegate패턴에서 사용)
프로토콜자체에서 채택가능한 타입을 지정하는 기능은 꽤 유용합니다.
먼저 다음처럼 delegate패턴을 AnyObject(클래스타입)에서만 채택이 가능하도록 구현합니다.
protocol SampleDelegate: AnyObject {
...
}
그러면 다음과 같이 weak 키워드를 프로퍼티에 적용시킬 수 있습니다.
delegate패턴에서 delegate에 상위클래스를 할당하기 때문에 자칫하면
그렇기 때문에 delegate패턴에서 다음과 같이 weak 키워드는 거의 필수입니다.
class SampleClass: UIView {
weak var delegate: SampleDelegate?
...
}