-
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 추상화와 다형성에 대해 얘기해볼까 합니다 🙋🏻
다들 객체 지향 프로그래밍, 즉 OOP라고 불리는것에 대해 특징을 알고 있을거에요.
추상화, 다형성, 캡슐화, 상속
그 중에서 해당 포스팅에서는 추상화와 다형성이라는것에 대해 한번 정리하고 넘어가보겠습니다.
처음 객체 지향이라는 개념을 접하실 때 은근히 많이 헷갈려하시는 포인트인것 같아서 최대한 쉽게 풀어보면서 전달 해보고 싶어서 이렇게 포스팅을 쓰게 되었습니다ㅎㅎ
혹시 OOP에 대해 무엇인지 처음 들어보신다면 우선 다른 문서들을 먼저 참고하고 오는것이 더 도움이 될것 같아요!
(나중에 여유가 있으면 OOP도 한번 정리해볼께요 😀)
그럼 추상화와 다형성 알아볼까요?
추상화
Abstraction이라고 불리는 객체지향의 중요한 요소중 하나인 추상화는 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 혹은 기능을 간추려 내는 것을 말한다고 사전적으로 정의되어 있습니다.
여기서 중요한 포인트는 핵심 개념 및 기능을 간추리는것에 있습니다.
즉, 여러 객체가 존재할 경우 이 객체들의 개념 혹은 기능들이 비슷하다면 추상화로 그 핵심 개념과 기능들을 추출하여 하나의 공통된 추상화로 만드는것입니다.
여기서 핵심 개념과 기능은 쉽게 말해 변수, 상수와 같은 속성과 메서드로 볼 수 있겠죠?
아래 그림으로 한번 추상화에 대해 정리를 해볼께요.
보면 하위로 각 세개의 자동차 객체가 있습니다.
그런데 이들 모두는 사실 공통된 속성과 메서드가 있죠.
예시로, 모두 이름이 있을것이고 시동을 거는 기능과 창문을 여는 기능이 있을거에요.
이렇게 세 객체 모두 차라는 하나의 공통된 추상화를 해줄 수가 있어요.
여기서 추상화를 할때 공통되게 가질 수 있는 속성과 메서드가 name 프로퍼티부터 openWindow 메서드라는것이구요!
즉, 벤츠부터 포르쉐까지 자동차의 특성을 가졌기에 이러한 각 다른 객체의 공통된 성질을 뽑아 하나의 공통된 추상화인 CAR를 만드는 과정을 "추상화 시킨다" 라고 표현할 수 있습니다 😮
추상화를 시켜두면 미리 공통된 속성과 메서드들을 한곳에 모아주니 더 관리하기도 쉽죠.
Swift에서는 추상화를 시켜주는 방법으로 두가지가 있습니다.
하나는 프로토콜을 사용하여 인터페이스로 추상화를 하는것이고, 하나는 클래스로 추상화를 구현하는것입니다.
하나씩 살펴볼까요?
Protocol로 추상화하기
우선 프로토콜로 추상화하는것에 대해 인터페이스를 구현한다고 할 수 있습니다.
즉, CAR라는 인터페이스를 구현할때 어떤 속성 및 기능들이 들어갈것인지 선언을 해두고 실제 이 인터페이스를 사용하는 객체에서 구체적인 속성과 기능들을 구현하여 사용하는 방식입니다.
흔히 POP(Protocol Oriented Programming)이라고 불리는 프로토콜 지향 프로그래밍이라고 Swift의 특징 중 하나이죠.
그럼 한번 코드로 살펴볼까요?
import Foundation protocol Car { // 자동차 이름 var name: String { get set } // 시동걸기 func start() // 창문 열기 func openWindow() } // Benz 객체 class Benz: Car { var name: String init(name: String) { self.name = name } func start() { print("\(name)가 벤츠 엔진을 작동시킵니다.") } func openWindow() { print("\(name)가 벤츠 창문을 엽니다.") } } // Tesla 객체 class Tesla: Car { var name: String init(name: String) { self.name = name } func start() { print("\(name)가 전기 모터를 작동시킵니다.") } func openWindow() { print("\(name)가 테슬라 창문을 엽니다.") } } // Benz, Tesla 객체 인스턴스 생성 let benz = Benz(name: "S-Class") let tesla = Tesla(name: "Model-X")
이렇게 Car라는 프로토콜로 자동차 이름부터 창문 열기까지 속성과 메서드들을 선언해줍니다.
그리고 실제로 Benz, Tesla 객체 클래스 생성 시 Car 프로토콜을 채택하여 name부터 openWindow 기능까지 각자에 맞게 구현해줍니다.
그럼 이제 각 Benz, Tesla 객체로 만든 인스턴스들은 각자 객체 타입에 맞는 속성과 기능을 동작하게 되는것이죠!
benz.start() tesla.start() // Prints - // S-Class가 벤츠 엔진을 작동시킵니다. // Model-X가 전기 모터를 작동시킵니다.
이렇게 말이죠!
즉, 각 start 엔진을 시동시키고 전원을 키는 기능적으로는 공통된 성질인데 이걸 추상화 없이 각 객체에서 매번 각자 일일히 넣어주기에는 공통 속성 및 기능들이 늘어나면 번거롭고 놓칠 수도 있죠.
그렇기에 프로토콜로 추상화를 시켜 우선적으로 자동차 타입의 객체들은 이러한 기능을 가질 수 있다는걸 청사진을 그려놓는겁니다.
또한 객체 지향에서는 추후 유지보수와 변경에 유연하도록 설계하는것이 중요한데, 그렇게 설계하기 위해서는 역할부와 구현부를 구분짓는것이 중요합니다.
이 중요한 역할을 해주는 포인트가 바로 추상화이죠!
그럼 추상 클래스를 이용하여 클래스로 추상화를 시켜볼까요?
Class로 추상화하기
우선 Java와는 달리 Swift에서는 추상 클래스를 기본적으로 지원하지 않습니다.
대신 프로토콜이라고 하는 인터페이스가 있는것이죠.
어떻게 보면 지금 우리가 하는 역할은 Java의 추상 클래스보다 Swift의 프로토콜 인터페이스 목적이 더 적합할 수 있습니다.
큰 하나의 차이점으로는 Java의 추상 클래스는 단일 상속이지만 프로토콜은 다중 상속이 됩니다.
그렇기에 Car 프로토콜을 객체가 채택하면서 Machine이라는 프로토콜도 같이 채택할 수 있죠.
Car와 Machine이 각자 갖는 속성과 기능이 다르기 때문에 필수적으로 구현해줘야하는 속성과 기능이 다를 수 있죠!
이럴 경우 인터페이스가 더 적합하겠죠?
자 잠깐 딴길로 빠졌는데, 그래서 Swift에선 추상 클래스가 없어서 왠만해선 프로토콜로 구현하면 되지만 정말 꼭 추상 클래스처럼 만들고싶다! 라고 한다면 강제로 만들어볼 수 있습니다.
추상 클래스 패턴을 이용해서 말이죠.
class Car { // 자동차 이름 var name: String // 이니셜라이저 init(name: String) { self.name = name } // 시동걸기 func start() { } // 창문 열기 func openWindow() { } } // Benz 객체 class Benz: Car { override init(name: String) { super.init(name: name) } override func start() { print("\(name)가 벤츠 엔진을 작동시킵니다.") } override func openWindow() { print("\(name)가 벤츠 창문을 엽니다.") } } let benz = Benz(name: "S-Class") benz.start() // S-Class가 벤츠 엔진을 작동시킵니다.
이렇게 구현해볼 수 있습니다.
Car를 클래스 타입으로 만들어주고 Car를 상속받는 하위 객체에서 override를 사용하여 각 역할을 프로토콜을 채택하고 실제 구현을 하는것처럼 기능을 구현할 수 있죠.
그런데 사실 이렇게까지 하기엔 프로토콜로도 충분히 더 간단히 할 수 있는데 왜 알아봐야 할까요?
이런 경우가 있을수도 있습니다.
Benz 객체에서 start 시 각 추상화로부터 만든 객체에서 구현한 print 출력문을 찍기 전 공통적으로 어떠한 코드를 실행해야 한다면?
코드로 볼까요?
class Car { // 자동차 이름 var name: String // 이니셜라이저 init(name: String) { self.name = name } // 시동걸기 func start() { 🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻 print("시동걸기") } // 창문 열기 func openWindow() { } } // Benz 객체 class Benz: Car { override init(name: String) { super.init(name: name) } override func start() { 🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻 super.start() print("\(name)가 벤츠 엔진을 작동시킵니다.") } override func openWindow() { print("\(name)가 벤츠 창문을 엽니다.") } } let benz = Benz(name: "S-Class") benz.start() // 시동걸기 // S-Class가 벤츠 엔진을 작동시킵니다.
사람이 손을 들고 있는 이모지 부분들의 코드를 봐주세요!
추가가 되었습니다 ㅎㅎ
이때 Car 추상 클래스 부분에서 start 메서드가 빈껍데기가 아닌 print("시동걸기")라는 기능을 하도록 구현해줍니다.
그리고 Car를 상속받은 Benz에선 start 시 super.start()로 상위 start 메서드를 호출하죠.
이럴 경우에 클래스로 추상화하는것이 조금 더 편할 수 있습니다.
이런 구현을 프로토콜로 하고 싶다면 추상 프로토콜을 확장하여 아래와 같이 구현해줄 수 있습니다.
protocol Car { var name: String { get set } func start() func openWindow() } extension Car { func start() { print("시동걸기") } } class Benz: Car { var name: String init(name: String) { self.name = name } func benzStart() { start() print("\(name)가 벤츠 엔진을 작동시킵니다.") } func openWindow() { print("\(name)가 벤츠 창문을 엽니다.") } } let benz = Benz(name: "S-Class") benz.benzStart() // 시동걸기 // S-Class가 벤츠 엔진을 작동시킵니다.
다만 Benz 클래스에서 Car를 채택하여 start 내부에서 같이 구현을 다시 해줘서 사용할 순 없고 별도의 하나의 메서드를 만들어서 start를 호출하여 사용할 수 있습니다.
즉, 결국 정리하자면 클래스 vs 프로토콜로 추상화를 선택할때는 각각의 스타일이나 조건에 차이도 있겠지만, 프로토콜은 다중 채택이 되기에 조금 더 확장성을 고려해볼 수 있기에 이러한 차이를 한번 생각하고 상황에 맞는 구현을 하는것이 좋을것 같습니다 🙋🏻
자 추상화에 대해 어느정도 기본 개념을 알 것 같다면 이제 두번째로 말하려고 했던 다형성에 대해 얘기해볼까요?
다형성
Polymorphism이라고 부르는 다형성은 사전적 의미로는 하나의 타입에 여러 형태를 가질 수 있는 성질을 말합니다.
즉, 같은 속성 및 기능에 대해 다양한 현태의 객체가 서로 다른 동작을 수행하게 하는 것이죠!
우리가 바로 위에서 추상화를 하면서 계속 같이 자연스럽게 나왔던 부분인데요.
benz는 Car를 상속 및 채택하여 start 메서드를 구현하고 tesla도 그와 마찬가지죠.
그런데 start 메서드의 기능은 benz와 tesla가 다릅니다.
이렇게 Benz, Tesla는 모두 부모 클래스나 프로토콜인 Car의 start() 메서드를 공통적으로 가지고 있지만, 각각 다른 동작을 수행합니다.
이처럼 다형성은 같은 함수 이름을 가진 메서드가 객체의 타입에 따라 다르게 동작하는 특성이죠!
결국 다형성은 하나의 인터페이스를 가지고 있지만, 다른 객체마다 그 인터페이스에 따라 다양한 동작을 수행할 수 있도록 하는 방법입니다.
코드 재사용성을 높여주는 동시에 유지보수를 훨씬 더 편하게 해줄 수 있죠!
Swift에서는 클래스로 상속 시 오버라이딩을 통해 다형성이 구현됩니다.
여기서 키 포인트가 상속과 오버라이딩이죠!
class Car { func start() { } } class Benz: Car { override func start() { print("벤츠 스타트") } } class Tesla: Car { override func start() { print("테슬라 스타트") } } let benz: Benz = Benz() let tesla: Tesla = Tesla() benz.start() tesla.start() // 벤츠 스타트 // 테슬라 스타트
이렇게 Car 클래스를 상속받고 오버라이드 키워드를 붙여 해당 start 메서드를 재정의 해줍니다.
또한 오버라이딩 말고도 오버로딩(Overloading)이라는 것도 있습니다.
오버로딩은 같은 이름의 메서드 명을 가지면서 매개변수의 타입과 갯수가 다른 경우를 뜻합니다.
즉, 오버로딩도 다형성의 한 형태입니다.
한번 예시로 볼까요?
import Foundation enum Old { case diesel case gasoline } enum New { case electric } class Car { func start(method: Old) { print("내연기관 스타트") } func start(method: New) { print("전기 스타트") } } let car = Car() car.start(method: .gasoline) car.start(method: .electric) // 내연기관 스타트 // 전기 스타트
간단히 내연기관을 Old 열거 타입으로 전기는 New 열거 타입으로 만들어줬습니다.
이때 Car 객체 구현 시 같은 메서드 명을 가진 start를 가지고 매개변수의 타입만 다른 두개의 메서드를 만들어줘요.
그리고 실제 car 인스턴스를 만들고 start 메서드 호출 시 method 매개변수에 Old, New에 해당하는 각기 다른 인자를 넣어준다면 그에 맞게 다르게 동작하는것을 볼 수 있습니다!
이것이 바로 오버로딩입니다.
오버로딩 또한 결국 다형성의 한 방법으로 코드 가독성을 높일 수 있고 편리함을 가져다 줍니다 😁
마무리
자 이렇게 이번 포스팅에서 객체 지향 프로그래밍의 꽃이라고 불릴 수 있는 추상화와 다형성에 대해 최대한 간단히 풀려고 노력해봤어요!
만약 읽으시면서 애매한게 있거나 간단하지 않고 어려운 부분이 있으셨다면 꼭 피드백 주시면 감사하겠습니다 🙇🏻
다음에는 캡슐화나 뭐 그런거 더 정리해볼수도..!?
레퍼런스
'Swift' 카테고리의 다른 글
Sequence를 알아보자 🤿 (111) 2024.01.29 Assertions & Preconditions (115) 2024.01.25 rethrows로 에러를 다시 던져보자 🥏 (40) 2023.11.03 String의 Small String Optimization (36) 2023.10.30 Default Initializers의 흔한 오해 (39) 2023.10.17