객체 지향 프로그래밍: OOP
Object-Oriented Programming(OOP) 객체 지향 프로그래밍은 디자인 철학입니다.
객체는 객체의 속성에 대한 정보를 포함하는 데이터 구조입니다.
객체는 현실 세계에서 나무가 될 수도 호수가 될 수도 있습니다.
Beer 라는 class로 표현을 해보면 이니셜라이저로 인스턴스가 생성될 때 모든 프로퍼티가 올바르게 초기화되도록 합니다.
class Beer {
var volume: Double
var canSize: Double
var description: String
init(volume: Double) {
self.volume = volume
self.canSize = 500
self.description = "치킨과 가장 어울리는 맥주"
}
func drinking(amount: Double) {
volume -= amount
}
}
쿨러가 있지만 맥주만 넣을 수 있기 때문에 불편할 수 있습니다.
class Cooler {
var cansOfBeer = [Beer]()
var maxCans: Int
init(maxCans: Int) {
self.maxCans = maxCans
}
func addBeer(beer: Beer) -> Bool {
guard cansOfBeer.count < maxCans else { return false }
cansOfBeer.append(beer)
return true
}
func removeBeer() -> Beer? {
guard !cansOfBeer.isEmpty else { return nil }
return cansOfBeer.removeFirst()
}
}
이것을 해결하기 위해 다형성을 사용합니다.
다형성은 여러 유형과 균일한 방식으로 상호 작용할 수 있는 기능을 제공합니다.
객체 지향 프로그래밍 언어를 사용하면 하위(자식) 클래스를 사용할 수 있습니다.
상위(부모) 클래스에서 상속한 프로퍼티와 메서드를 재정의하거나 자체 프로퍼티와 메서드를 추가할 수 있습니다.
다형성을 통해 기존의 사이즈를 하드 코딩하기보다는 DrinkSize 라는 열거형으로 다양한 크기의 음료를 만듭니다.
그리고 모든 음료 유형이 파생될 슈퍼 클래스 Drink 입니다.
enum DrinkSize {
case small // 330
case midium // 500
case big // 750
}
class Drink {
var volume: Double
var canSize: DrinkSize
var description: String
init(volume: Double, canSize: DrinkSize) {
self.volume = volume
self.canSize = canSize
self.description = "모든 음료의 슈퍼 클래스"
}
func drinking(amount: Double) {
volume -= amount
}
}
기존의 쿨러와 맥주는 아래와 같이 변경됩니다.
class Beer: Drink {
init(volume: Double) {
super.init(volume: volume, canSize: DrinkSize.midium)
self.description = "치킨과 가장 어울리는 맥주"
}
}
class Cooler {
var cansOfDrink = [Drink]()
var maxCans: Int
init(maxCans: Int) {
self.maxCans = maxCans
}
func addDrink(drink: Drink) -> Bool {
guard cansOfDrink.count < maxCans else { return false }
cansOfDrink.append(drink)
return true
}
func removeBeer() -> Drink? {
guard !cansOfDrink.isEmpty else { return nil }
return cansOfDrink.removeFirst()
}
}
이렇게 사용하는 것은 문제점이 2가지 존재합니다.
첫 번째는 하위 클래스가 상위 클래스의 이니셜라이저를 항상 호출해야 합니다. 주의하지 않으면 부적절한 초기화가 발생할 수 있습니다.
그리고 다른 문제점은 참조 유형을 사용한다는 점입니다. 인스턴스를 전달할 때 원본 인스턴스에 대한 참조를 전달하게 되면서 예상과는 다르게 여러 개의 인스턴스 상태가 동일하게 변경될 수 있습니다.
또한, 클래스는 하나의 상위 클래스만 가질 수 있다는 점으로 하위 클래스에서 필요하거나 원하지 않는 코드를 포함될 가능성이 있게 됩니다.
프로토콜 지향 프로그래밍: POP
POP로 다시 설계하려면 다시 생각해야 합니다. 슈퍼클래스가 아닌 프로토콜로 시작해야 합니다.
또 변경해야 되는 것은 참조(클래스) 타입입니다.
Swift에서는 참조 유형보다 값 유형을 사용하는 것이 바람직하다고 명시합니다.
Drink 프로토콜을 준수하는 모든 유형은 자동적으로 drinking() 메서드를 자동으로 받습니다.
protocol Drink {
var volume: Double { get set }
var drinkSize: DrinkSize { get set }
var description: String { get set }
}
extension Drink {
mutating func drinking(amount: Double) {
volume -= amount
}
}
struct Beer: Drink {
var volume: Double
var drinkSize: DrinkSize
var description: String
init(volume: Double, drinkSize: DrinkSize) {
self.volume = volume
self.drinkSize = drinkSize
self.description = "치킨과 함께라면 언제나 성공적"
}
}
var roseBeer = Beer(volume: 500, drinkSize: .midium)
roseBeer.drinking(amount: 300)
여러 부분이 쿨러 인스턴스와 상호 작용해야 하기 때문에, 쿨러는 값 타입이 아닌 참조 타입으로 구현하는 게 좋습니다.
Apple에서도 확실하지 않은 경우에는 값 유형을 사용하는 것을 권장합니다.
Protocol 그리고 extension
개발자는 제대로 작동하는 애플리케이션을 개발이 중요하지만, 깔끔하고 안전한 코드를 작성하는 데에도 집중해야 합니다.
Clean Code는 읽고 이해하기 쉬운 코드를 뜻하며, 안전한 코드는 코드를 조금만 변경했는데 코드 베이스 전체에 오휴가 생긴다던가 하는 문제점이 있습니다.
protocol / protocol extension 으로 사용되는 것은 선호에 따라 다르겠지만, 좀 더 읽기 쉽다고 생각합니다. protocol / protocol extension 이 슈퍼 클래스에 비해 갖는 3가지의 장점
- 타입이 여러 프로토콜 준수
- extension을 통해 원본 코드 없이도 기능 추가
- 클래스뿐만 아니라 구조체, 열거형에서도 채택 가능
슈퍼클래스로 정의된 것보다 프로토콜을 채택한 것이 구현하는 부분에서 더 길어질 수 있지만, 훨씬 안전하고 읽기 쉽습니다.
하위 클래스의 프로퍼티를 이해하기 위해서는 슈퍼클래스를 봐야만 하지만, 프로토콜을 채택한 것은 어떻게 구현되고 초기화되는지 확인할 수 있습니다.
SOLID 원칙
모듈 혹은 객체에서 각 기능들이 얼마나 연관되어 있는지 나타내는 응집도가 있습니다.
응집도가 높다는 말은 각 기능이 밀접하게 작동하고 낮다는 말은 서로 별도의 작업을 수행한다는 의미입니다.
모듈 혹은 객체 간 얼마나 의존하고 있는지를 나타내는 결합도가 있습니다.
결합도가 높다는 말은 한 모듈이 다른 모듈까지 영향을 미칠 수 있고 낮다는 말은 다른 모듈에 최소한의 영향을 미친다는 의미입니다.
protocol을 사용하면 응집도가 높은 책임을 묶을 수 있고 구현체를 직접 사용하지 않기 때문에 결합도를 낮출 수 있습니다.
DIP 의존성 역전 원칙은 구체적인 객체가 아닌 추상화에 의존해야 하는 것으로 protocol로 의존성을 분리할 수 있습니다.
protocol Buyable {
func getPrice() -> Int
}
final class A {
private let stuff: Buyable
init(stuff: Buyable) {
self.stuff = stuff
}
func tellPrice() {
print(stuff.getPrice)
}
}
final class Book: Buyable {
func getPrice() -> Int {
35_000
}
}
A에서 stuff를 Book으로 지칭하는 게 아닌 protocol을 사용함으로써 Book에서 변경된다고 해서 A에서 에러가 발생하지 않습니다.
Book 자체의 기능을 교체한다고 해도 기존 코드에 영향이 없게 됩니다.
유닛 테스트 하기 어려운 부분도 테스트하기 쉬워집니다.
A를 테스트 하기 위해서 Mock 객체를 만들어서 사용하기에 수월한 테스트가 가능해집니다.
protocol을 채택한 것은 의존성의 방향이 역전되었다는 말입니다.
OCP 개방 폐쇄 원칙은 기능 추가, 변경해도 기존 코드에 영향이 없어야 합니다.
enum MovieGenre {
case action
case romance
}
final class Cinema {
func show(genre: MovieGenre, title: String) {
switch genre:
case .action:
print("액션 영화 \(title)가 시작합니다")
case .romance:
print("로맨스 영화 \(title)가 시작합니다")
}
}
// 다른 장르가 추가 된다면 Cinema에서는 에러 발생
// 해결하기 위해 protocol 사용
protocol MovieGenre {
func show(title: String)
}
final class ActionGenre: MovieGenre {
func show(title: String) {
print("액션 영화 \(title)가 시작합니다")
}
}
protocol을 사용함으로써 확장에 대해서 열린 구조가 됩니다.
LSP 리스코프 치환 원칙은 하위 타입에 의존해서는 안되고, 상위 타입으로 교체 가능해야 합니다.
프로토콜을 사용해서 구현체를 직접 쓰지말고, 다운캐스팅 하지 말라는 의미입니다.
SRP 단일 책임 원칙은 하나의 역할을 하라는 의미로 객체의 역할에 맞는 기능을 가져야 합니다.
protocol을 사용하게 되면 SOLID 원칙에 맞게 사용할 수 있습니다.
분리를 하게 되면 자연스럽게 파일이 많아지지만, 그만큼 네이밍을 잘 사용해야 합니다.
Swift에서는 주로 형용사의 형태로 protocol의 이름을 사용하지만, 명사로도 사용되는 경우가 있습니다.
읽기 쉬운 코드를 작성하기 위해 주어진 역할에 맞는 네이밍 센스가 필요하다. GPT 도와줘
'Language > Swift' 카테고리의 다른 글
[Swift] Reactive Programming Combine - 2: Publishers & Subscribers (0) | 2024.05.04 |
---|---|
[Swift] Reactive Programming Combine - 1 (1) | 2024.05.01 |
UIActivityViewController 공유하는 그거 (0) | 2024.03.08 |
[Swift] 알고리즘을 대비한 메서드들 (0) | 2024.02.18 |
[Swift] UIImage, CGImage 그리고 CIImage 언제 사용되나 (0) | 2024.02.14 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!