ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 코틀린(kotlin) - 문자열계산기
    프로젝트/Kotlin + TDD 2023. 9. 3. 00:01
    728x90

    개요

    사내스터디를 통해 문자열계산기를 각자 만들어보고 코드리뷰하는 시간을 가졌습니다.

    이때 구현했던 코드와 고민들을 공유해보고자 합니다.

     

    코드는 github 저장소에서 보실 수 있습니다.

    https://github.com/Junuu/kotlin-racingcar/tree/junuu

     

     

    요구사항정리 - Readme.md

    기능구현에 대해 요구사항을 정리하고 단계별로 구현해 나갔습니다.

    초기에 덧셈, 뺄셈까지는 코드의 구조를 잡느라 시간이 오래 걸렸지만 이후에는 금방금방 구현이 가능했습니다.

     

    Calculator Object

    object Calculator {
        fun runCalculator(input: String?): Int {
            val elementStorage = CalculatorInputClassifier.splitElementAndValidator(input)
            while (elementStorage.isCalculateContinue()) {
                val calculateInfo = elementStorage.getCalculateInfo()
                val calculatedValue = calculate(calculateInfo)
                elementStorage.putOperatedNumber(calculatedValue)
            }
            return elementStorage.getResult()
        }
    
        private fun calculate(calculateInfo: CalculateInfo): String{
            val result = when(calculateInfo.operation){
                Operation.PLUS -> calculateInfo.getFirstValue() + calculateInfo.getSecondValue()
                Operation.MINUS -> calculateInfo.getFirstValue() - calculateInfo.getSecondValue()
                Operation.MULTIPLE -> calculateInfo.getFirstValue() * calculateInfo.getSecondValue()
                Operation.DIVIDE -> runCatching {
                    calculateInfo.getFirstValue() / calculateInfo.getSecondValue()
                }.getOrDefault(0)
            }
            return result.toString()
        }
    }

     

    Calculator는 전체계산을 관리합니다, 크게 보면 순서는 다음과 같습니다.

    1. 문자열 파싱 및 검증 (CalculatorInputClassifier)

    2. 계산 (CalculatorElementStorage 일급컬렉션, Operation Enum 활용)

     

    Kotlin의 When과 Enum을 함께 활용하였으며, 더 나아간다면 Calculator interface를 만들고 이를 상속받는 PlusCalculator, MinusCacluator를 만들어 Enum마다 다른 계산기들을 호출하고 계산로직은 내부로 숨길 수 있을 것 같습니다.

     

    또는 Enum이 계산하는 로직을 갖도록 

     

    CalculatorInputClassifier Object

    object CalculatorInputClassifier {
        fun splitElementAndValidator(input: String?): CalculatorElementStorage {
            require(input.isNullOrBlank() == false)
            val inputSplit = input.split(" ")
            return CalculatorElementStorage(inputSplit)
        }
    }

    "2 + 3 + 5"처럼 들어온 요소들을 split 하고, validation 하는 책임을 집니다.

    계산기의 정책 중 하나로 null이 들어오거나 빈 문자열이 들어오면 IllegalArgumentException을 반환해야 합니다.

    이후에는 해당 문자열을 CalculatorElementStorage객체로 만들어 반환합니다.

     

    CalculatorElementStorage Class - 일급 컬렉션

    class CalculatorElementStorage(
        splitElement: List<String>,
    ) {
        private val _storage: ArrayDeque<String> = ArrayDeque()
    
        init{
            saveCalculatorElement(splitElement)
        }
    
        fun isCalculateContinue(): Boolean {
            if (_storage.size >= 3) {
                return true
            }
            return false
        }
    
        fun getCalculateInfo(): CalculateInfo {
            val firstOperatedValue = FirstOperatedValue(_storage.pollFirst().toInt())
            val operation = _storage.pollFirst()
            val  secondOperatedValue = SecondOperatedValue(_storage.pollFirst().toInt())
            return CalculateInfo(
                calculateNumbers = CalculateNumbers(firstOperatedValue, secondOperatedValue),
                operation = Operation.operationOf(operation),
            )
        }
    
        fun putOperatedNumber(operatedNumber: String){
            _storage.addFirst(operatedNumber)
        }
    
        fun getResult(): Int {
            return _storage.poll()
                .toInt()
        }
    
        private fun saveCalculatorElement(splitElement: List<String>){
            splitElement.forEach { _storage.addLast(it) }
        }
    }

    1. 이미 요소가 분할된 List를 받아 ArrayDeque를 하나 만들어냅니다.

    2. 요소가 3개 이상 (그리고 숫자, 연산자, 숫자) 순서로 이루어져야 합니다.

    3. 이후에는 CalculateInfo에 연산할 Number Value들과 연산자를 담아 반환합니다.

    4. 연산이 끝난 후 추가적으로 처리해야 할 수 있습니다 예를 들어 (3 + 6 + 9)라면 (3 + 6)을 CalculateInfo로 만들어 연산을 처리하고 9라는 결과를 다시 CalculatorElementStorage에 넣어줍니다. (이때는 ArrayDeque를 활용해 맨 앞에 넣어줍니다)

    그래야  (9 + 9)가 다음연산이 가능해집니다.

    5. 만약 isCalculateContinue가 false 즉, 계산이 불가능하다면 결과를 꺼내 반환합니다.

     

    사용한 Value Object와 Enum

    class CalculateInfo(
        val calculateNumbers: CalculateNumbers,
        val operation: Operation,
    ){
        fun getFirstValue(): Int{
            return calculateNumbers.firstOperatedValue.value
        }
    
        fun getSecondValue(): Int{
            return calculateNumbers.secondOperatedValue.value
        }
    }
    
    class CalculateNumbers(
        val firstOperatedValue: FirstOperatedValue,
        val secondOperatedValue: SecondOperatedValue,
    )
    
    @JvmInline
    value class FirstOperatedValue(
        val value: Int,
    )
    
    @JvmInline
    value class SecondOperatedValue(
        val value: Int,
    )
    
    enum class Operation(val value: String) {
        PLUS("+"),
        MINUS("-"),
        MULTIPLE("*"),
        DIVIDE("/"),
        ;
    
        companion object {
            fun operationOf(inputValue: String): Operation {
                return values().firstOrNull { it.value == inputValue }
                    ?: throw IllegalArgumentException("사칙 연산 기호 이외에는 들어오면 안됩니다.(value=$inputValue)")
            }
        }
    }

    개선건으로는 FirstOperatedValue, SecondOperatedValue는 CalculatorNumber로 통일시켜서 Value Object로 활용할 수 있을 것 같습니다.

    calculatNumbers.firstOperatedValue.value 처럼 .을 계속 찍어 접근하는 것보다는 backing property나 메서드를 활용하여 적절하게 끊어낼 수 있을 것 같습니다.

     

     

    구현한 테스트

    class CalculatorTest: AnnotationSpec() {
    
        private val sut = Calculator
    
        @Test
        fun `사칙연산 이외에는 연산자가 들어올 수 없다`(){
            val input = "3 ㄱ 3"
    
            shouldThrow<IllegalArgumentException> {
                sut.runCalculator(input)
            }
        }
    
        @Test
        fun `계산기의 입력값으로는 null 이 들어올 수 없다`(){
            val input = null
    
            shouldThrow<IllegalArgumentException> {
                sut.runCalculator(input)
            }
        }
    
        @Test
        fun `계산기의 입력값으로는 빈문자가 들어올 수 없다`(){
            val input = "  "
            shouldThrow<IllegalArgumentException> {
                sut.runCalculator(input)
            }
        }
    
        @ParameterizedTest
        @MethodSource("plusCalculatorInputAndResult")
        fun `계산기에 두 숫자를 넣으면 덧셈을 수행한다`(input: String, expectedResult: Int){
    
            val calculatorResult = sut.runCalculator(input)
    
            calculatorResult shouldBe expectedResult
        }
    
    
        @ParameterizedTest
        @MethodSource("minusCalculatorInputAndResult")
        fun `계산기에 두 숫자를 넣으면 뺄셈을 수행한다`(input: String, expectedResult: Int){
    
            val calculatorResult = sut.runCalculator(input)
    
            calculatorResult shouldBe expectedResult
        }
    
        @ParameterizedTest
        @MethodSource("multipleCalculatorInputAndResult")
        fun `계산기에 두 숫자를 넣으면 곱셈을 수행한다`(input: String, expectedResult: Int){
    
            val calculatorResult = sut.runCalculator(input)
    
            calculatorResult shouldBe expectedResult
        }
    
        @ParameterizedTest
        @MethodSource("divideCalculatorInputAndResult")
        fun `계산기에 두 숫자를 넣으면 나머지연산을 수행한다`(input: String, expectedResult: Int){
    
            val calculatorResult = sut.runCalculator(input)
    
            calculatorResult shouldBe expectedResult
        }
    
        @ParameterizedTest
        @MethodSource("allInOneCalculatorInputAndResult")
        fun `계산기에 사칙연산을 모두 포함하는 연산을 넣으면 계산을 수행한다`(input: String, expectedResult: Int){
    
            val calculatorResult = sut.runCalculator(input)
    
            calculatorResult shouldBe expectedResult
        }
    
    
        companion object {
            @JvmStatic
            fun plusCalculatorInputAndResult(): Stream<Arguments> {
                return Stream.of(
                    Arguments.of("2 + 3 + 10", 15),
                    Arguments.of("8 + 9", 17),
                    Arguments.of("5 + 5", 10),
                )
            }
    
            @JvmStatic
            fun minusCalculatorInputAndResult(): Stream<Arguments> {
                return Stream.of(
                    Arguments.of("5 - 3", 2),
                    Arguments.of("10 - 3", 7),
                    Arguments.of("15 - 8 - 1 - 1", 5),
                )
            }
    
            @JvmStatic
            fun multipleCalculatorInputAndResult(): Stream<Arguments> {
                return Stream.of(
                    Arguments.of("5 * 3", 15),
                    Arguments.of("10 * 3 * 3", 90),
                    Arguments.of("15 * 0", 0),
                )
            }
    
            @JvmStatic
            fun divideCalculatorInputAndResult(): Stream<Arguments> {
                return Stream.of(
                    Arguments.of("5 / 3", 1),
                    Arguments.of("30 / 3 / 2", 5),
                    Arguments.of("15 / 0", 0),
                    Arguments.of("0 / 15", 0),
                )
            }
    
            @JvmStatic
            fun allInOneCalculatorInputAndResult(): Stream<Arguments> {
                return Stream.of(
                    Arguments.of("5 / 3 + 3 - 2", 2),
                    Arguments.of("2 + 3 * 4 / 2", 10),
                    Arguments.of("15 / 0 + 5 * 20", 100),
                    Arguments.of("0 / 15 + 3", 3),
                )
            }
        }
    }

    @Parameterized 테스트를 활용하여 구현하였는데 Kotlin의 kotest에는 data driven testing이라는 방법도 존재합니다.

    댓글

Designed by Tistory.