[내배캠] Swift 기초 개인 과제

nbcamp swift
Written on Jul 19, 2023


본 캠프가 시작되고 2주차가 되었습니다. (2023년 7월 17일 ~ 21일)

이번 주차에선 Swift 기초를 학습하고 간단한 연산 기능을 제공하는 CLI 계산기를 만들어 보는 과제를 수행했습니다.


요구조건

  • 1단계: 덧셈, 뺄셈, 곱셈, 나눗셈 연산 기능을 제공하는 Calculator 클래스 구현
  • 2단계: 1단계에서 구현한 Calculator 클래스를에 나머지 연산 기능 추가
  • 3단계: 각 연산을 개별 연산 클래스로 분리하고 Calculator와 연결 (feat. 단일 책임 원칙)
  • 4단계: 연산 클래스를 추상화한 추상 클래스 작성 (feat. 결합도, 의존성 역전 원칙)

프로젝트 구조

Language:plaintext
.
├── Sources/
│  ├── Operators/
│  │  ├── AddOperator.swift
│  │  ├── SubOperator.swift
│  │  ├── MulOperator.swift
│  │  ├── DivOperator.swift
│  │  ├── ModOperator.swift
│  │  └── Operator.swift
│  ├── Calculator.swift
│  └── main.swift
├── Package.swift
└── README.md

프로젝트 구현

개발 환경설정

과제에서는 단순히 연산 후 출력하는 걸 요구하고 있지만, 저는 readLine 함수를 활용하여 사용자로부터 입력을 받아 계속 연산이 가능하도록 구현했습니다.

실행 가능한 파일로 작성하기 위해 해당 문서를 참고하여 프로젝트를 생성했습니다.

Language:bash
$ swift package init --type executable

위 명령어를 실행하면 현재 위치한 폴더에 Package.swift 파일을 생성합니다.

Language:swift
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "calculator-cli",
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.,
        // Targets can depend on other targets in this package and products from dependencies.
        .executableTarget(
            name: "calc",
            path: "Sources"
        ),
    ]
)

이 파일에서 프로젝트에 대한 의존성 관리 및 빌드 설정 등을 할 수 있습니다.

Sources 폴더에 main.swift 또한 생성되므로 swift run 명령어를 실행하여 프로젝트를 실행할 수 있습니다.

Operator protocol 구현

각 연산에 대한 클래스의 명세를 정하기 위해 Operator 프로토콜을 작성했습니다.

Language:swift
protocol Operator {
    func operate<Operand>(_ lhs: Operand, _ rhs: Operand) -> Operand
}

이를 토대로 덧셈, 뺄셈, 곱셈, 나눗셈, 나머지 연산 클래스를 작성했습니다.

Language:swift
class AddOperator: Operator {
    func operate<Operand>(_ lhs: Operand, _ rhs: Operand) -> Operand {
        return lhs + rhs
    }
}

여기서 lhs + rhs 에서 Binary operator '+' cannot be applied to two 'Operand' operands 에러가 발생합니다. Operand 타입이 덧셈 기능을 제공하는지 모르기 때문입니다.

이를 해결하려면 Operand 타입이 연산 가능한 타입만 올 수 있도록 제약을 걸 필요가 있습니다. Calculable 프로토콜을 추가하여 이를 준수하는 타입만 올 수 있도록 제약을 걸었습니다.

Language:swift
protocol Operator {
    func operate<Operand: Calculable>(_ lhs: Operand, _ rhs: Operand) -> Operand
}

protocol Calculable {
    static func +(lhs: Self, rhs: Self) -> Self
    static func -(lhs: Self, rhs: Self) -> Self
    static func *(lhs: Self, rhs: Self) -> Self
    static func /(lhs: Self, rhs: Self) -> Self
    static func %(lhs: Self, rhs: Self) -> Self
}

extension Int: Calculable {}

Calculable 프로토콜을 추가하고 Int 타입이 이를 준수하도록 확장(extension)했습니다.

하지만, 위와 같은 방식으로 Double 타입을 확장했을 때, Type 'Double' does not conform to protocol 'Calculable' 에러가 발생합니다. 실수 타입인 Double은 나머지 연산에 대한 동작이 정의되어 있지 않기 때문입니다. 단순히 정수 타입으로 동작하도록 확장해줍니다.

Language:swift
extension Double: Calculable {
    static func %(lhs: Self, rhs: Self) -> Self {
        return Double(Int(lhs) % Int(rhs))
    }
}
Language:swift
class ModOperator: Operator {
    func operate<Operand>(_ lhs: Operand, _ rhs: Operand) -> Operand where Operand : Calculable {
        return lhs % rhs
    }
}

이로써 나머지 연산에 대해서도 Double 타입을 사용할 수 있게 되었고, Operator 프로토콜을 준수하는 ModOperator 클래스를 작성할 수 있게 되었습니다. (SPR. 단일 책임 원칙)

Calculator class 구현

작성한 연산자를 언제든 사용할 수 있는 형태로 하여 Dictionary 형태로 외부에서 주입할 수 있도록 작성했습니다.

_result는 여태까지 연산한 결과를 갖고 있는 저장 프로퍼티이고, calculate 메서드를 호출하여 연산을 수행합니다.

Language:swift
import Foundation

class Calculator<T: Calculable> {
    private var _result: T
    private var _operators: [String: Operator]

    init(defaultValue: T? = nil, operators: [String: Operator] = [:]) {
        _result = defaultValue ?? Calculator.zero()
        _operators = operators
    }

    var result: T { _result }
    var operators: [String] { Array(_operators.keys) }

    @discardableResult
    func calculate(_ operand: T, name: String) -> T {
        guard let operation = _operators[name] else { return _result }
        _result = operation.operate(_result, operand)
        return _result
    }
}
Language:swift
let calculator = Calculator<Double>(
    operators: [
        "+": AddOperator(),
        "-": SubOperator(),
        "*": MulOperator(),
        "/": DivOperator(),
        "%": ModOperator(),
    ]
)

calculator.calculate(10, name: "+")

이로써 Calculator는 구체적인 클래스가 아닌 추상화된 프로토콜에 의존합니다. (DIP. 의존성 역전 원칙)

트러블슈팅

1. 0으로 초기화할 때 제네릭 타입으로 변환할 수 없는 문제

계산기이니 clear 메서드를 작성했고, 이는 결과를 0으로 초기화하는 단순한 작업을 수행합니다. Int 타입은 0으로 초기화하는 반면에 Double은 0.0으로 초기화해야 했기 때문입니다. 제네릭 타입으로부터 이를 확인할 수 있는 방법은 없었지만, 0과 0.0 이외에 경우는 없다고 가정하고 nullish coalescing operator를 사용하여 해결했습니다.

Language:swift
private static func zero() -> T {
    return 0 as? T ?? 0.0 as! T
}

2. 첫 문자를 제외한 문자열 가져오기

Swift에서 문자열을 조작하기란 다른 언어에 비해 번거로운 점이 많았습니다… index 또한 단순히 숫자가 아니라 String.Index를 생성하여 전달해야했고, 주어진 Index로부터 어느정도 떨어졌는지하는 방식으로 문자열을 가져와야했습니다.

첫 문자를 제외하고 문자열을 가져오려면 다음 방식으로 가져와야 합니다.

Language:swift
let input: String = "Hello, World!"
input[input.index(input.startIndex, offsetBy: 1)...] // ello, World!
  1. input.index 메서드를 호출하여 String.Index 타입의 인덱스를 생성합니다. 이때, input.startIndex를 기준으로 offsetBy 만큼 떨어진 인덱스를 생성합니다.
  2. PartialRangeFrom 문법을 활용하여 해당 인덱스부터 문자열을 가져옵니다.

나중에 알게된 내용인데 단순하게 dropFirst 메서드를 활용해도 됩니다.

Language:swift
input.dropFirst() // ello, World!

문자열 관련 메서드는 대개 Self.SubSequence 타입을 반환합니다. 잘라낸 문자열을 저장하기 위헤 새로운 메모리 공간을 할당하는 것이 아닌 기존의 문자열에서 필요한 부분에 직접 접근하기 때문입니다. 따라서, 문자열로서 사용하고 싶다면 String으로 변환해야 합니다.

회고

객체 지향 프로그래밍의 5대 원칙 중 단일 책임 원칙(Single Responsibility Principle)과 의존성 역전 원칙(Dependency Inversion Principle)을 적용하여 계산기를 구현해보았습니다. 원칙을 적용해보기 위해 프로토콜로 명세를 작성하고 이를 준수하는 클래스는 작성하는 작업을 진행하면서 코드의 중복을 제거하고 확장성 높은 프로그램을 작성할 수 있었습니다.

앞으로 이외에도 적용할 수 있는 원칙을 찾아보고 적합한 디자인 패턴 및 기법을 추가로 학습하여 적용해보려고 합니다.