-
코틀린(kotlin) - 문자열계산기프로젝트/Kotlin + TDD 2023. 9. 3. 00:01
개요
사내스터디를 통해 문자열계산기를 각자 만들어보고 코드리뷰하는 시간을 가졌습니다.
이때 구현했던 코드와 고민들을 공유해보고자 합니다.
코드는 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이라는 방법도 존재합니다.
'프로젝트 > Kotlin + TDD' 카테고리의 다른 글
코틀린(kotlin) - 숫자 야구 게임 (0) 2022.10.22 코틀린(kotlin) - 자동차 경주 게임 (0) 2022.10.18 코틀린 프로젝트 시작하기 (0) 2022.10.16