From d65fc0f9e8915bcc68ef01f4af778464eba3855c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=9B=90=28Backend=ED=8C=80=29?= <26001202+JiwonDev@users.noreply.github.com> Date: Mon, 21 Aug 2023 12:13:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20step2=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EA=B8=B0=20=EA=B5=AC=ED=98=84=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: step2 문자열 계산기 구현 * feat: [step2] 패키지 명 변경 * feat: [step2] calculator 리팩토링 및 의미있는 이름으로 클래스명 변경 --- src/main/kotlin/{step1 => person}/Person.kt | 2 +- src/main/kotlin/racingcar/Step2Application.kt | 4 + .../racingcar/application/CalculateUseCase.kt | 24 +++++ .../racingcar/service/ExpressionCalculator.kt | 62 +++++++++++++ .../racingcar/service/StringTokenizer.kt | 29 ++++++ .../service/Token2ExpressionConvertor.kt | 61 ++++++++++++ .../kotlin/racingcar/type/ExpressionRegex.kt | 8 ++ .../kotlin/racingcar/type/ExpressionType.kt | 5 + .../kotlin/racingcar/type/OperatorType.kt | 8 ++ src/main/kotlin/racingcar/vo/Expression.kt | 8 ++ .../kotlin/racingcar/vo/ExpressionToken.kt | 28 ++++++ src/main/kotlin/racingcar/vo/RawToken.kt | 3 + .../kotlin/{step1 => person}/PersonTest.kt | 2 +- .../application/CalculateUseCaseTest.kt | 93 +++++++++++++++++++ 14 files changed, 335 insertions(+), 2 deletions(-) rename src/main/kotlin/{step1 => person}/Person.kt (87%) create mode 100644 src/main/kotlin/racingcar/Step2Application.kt create mode 100644 src/main/kotlin/racingcar/application/CalculateUseCase.kt create mode 100644 src/main/kotlin/racingcar/service/ExpressionCalculator.kt create mode 100644 src/main/kotlin/racingcar/service/StringTokenizer.kt create mode 100644 src/main/kotlin/racingcar/service/Token2ExpressionConvertor.kt create mode 100644 src/main/kotlin/racingcar/type/ExpressionRegex.kt create mode 100644 src/main/kotlin/racingcar/type/ExpressionType.kt create mode 100644 src/main/kotlin/racingcar/type/OperatorType.kt create mode 100644 src/main/kotlin/racingcar/vo/Expression.kt create mode 100644 src/main/kotlin/racingcar/vo/ExpressionToken.kt create mode 100644 src/main/kotlin/racingcar/vo/RawToken.kt rename src/test/kotlin/{step1 => person}/PersonTest.kt (99%) create mode 100644 src/test/kotlin/racingcar/application/CalculateUseCaseTest.kt diff --git a/src/main/kotlin/step1/Person.kt b/src/main/kotlin/person/Person.kt similarity index 87% rename from src/main/kotlin/step1/Person.kt rename to src/main/kotlin/person/Person.kt index cc94b37..e5c3bc5 100644 --- a/src/main/kotlin/step1/Person.kt +++ b/src/main/kotlin/person/Person.kt @@ -1,4 +1,4 @@ -package step1 +package person data class Person( val name: String, diff --git a/src/main/kotlin/racingcar/Step2Application.kt b/src/main/kotlin/racingcar/Step2Application.kt new file mode 100644 index 0000000..8ef08ea --- /dev/null +++ b/src/main/kotlin/racingcar/Step2Application.kt @@ -0,0 +1,4 @@ +package racingcar + +fun main(args: Array) { +} diff --git a/src/main/kotlin/racingcar/application/CalculateUseCase.kt b/src/main/kotlin/racingcar/application/CalculateUseCase.kt new file mode 100644 index 0000000..0ecf345 --- /dev/null +++ b/src/main/kotlin/racingcar/application/CalculateUseCase.kt @@ -0,0 +1,24 @@ +package racingcar.application + +import racingcar.service.ExpressionCalculator +import racingcar.service.StringTokenizer +import racingcar.service.Token2ExpressionConvertor +import racingcar.vo.Expression +import racingcar.vo.RawToken + +class CalculateUseCase( + private val stringTokenizer: StringTokenizer, + private val token2ExpressionConvertor: Token2ExpressionConvertor, + private val expressionCalculator: ExpressionCalculator, +) { + fun calculate(stringExpression: String): Double { + require(stringExpression.isNotBlank()) { "빈 표현식은 계산할 수 없습니다." } + + val tokens: List = stringTokenizer.parse(expression = stringExpression) + + val expression: Expression = token2ExpressionConvertor.convert(tokens = tokens) + + require(expressionCalculator.isCalculable(expression)) { "계산할 수 없는 표현식 타입입니다." } + return expressionCalculator.calculate(expression = expression) + } +} diff --git a/src/main/kotlin/racingcar/service/ExpressionCalculator.kt b/src/main/kotlin/racingcar/service/ExpressionCalculator.kt new file mode 100644 index 0000000..480cc3d --- /dev/null +++ b/src/main/kotlin/racingcar/service/ExpressionCalculator.kt @@ -0,0 +1,62 @@ +package racingcar.service + +import racingcar.type.ExpressionType +import racingcar.type.OperatorType +import racingcar.vo.Expression +import racingcar.vo.ExpressionToken +import racingcar.vo.NumberToken +import racingcar.vo.OperatorToken + +interface ExpressionCalculator { + fun isCalculable(expression: Expression): Boolean + + fun calculate(expression: Expression): Double +} + +class PostfixExpressionCalculator : ExpressionCalculator { + override fun isCalculable(expression: Expression): Boolean { + return expression.type == ExpressionType.POSTFIX + } + + override fun calculate(expression: Expression): Double { + val stack: ArrayDeque = expression.tokens + .fold(ArrayDeque()) { stack, token -> stack.calculateToken(token) } + + require(stack.size == 1) { "계산이 완료되지 않은 표현식입니다." } + + val result: NumberToken = stack.removeLastOrNull() as? NumberToken + ?: throw IllegalArgumentException("계산이 완료되지 않은 표현식입니다.") + + return result.value + } + + private fun ArrayDeque.calculateToken(token: ExpressionToken): ArrayDeque { + when (token) { + is NumberToken -> this.addLast(token) + + is OperatorToken -> { + val number2 = this.removeLastOrNull() + val number1 = this.removeLastOrNull() + this.addLast(NumberToken(evaluate(token, number1, number2))) + } + } + + return this + } + + private fun evaluate(operator: OperatorToken, number1: ExpressionToken?, number2: ExpressionToken?): Double { + if (number1 !is NumberToken || number2 !is NumberToken) { + throw IllegalArgumentException("잘못된 계산 식입니다.") + } + + return when (operator.operatorType) { + OperatorType.ADD -> number1.value + number2.value + OperatorType.SUBTRACT -> number1.value - number2.value + OperatorType.MULTIPLY -> number1.value * number2.value + OperatorType.DIVIDE -> { + require(number2.value != 0.0) { "0으로 나눌 수 없습니다." } + number1.value / number2.value + } + } + } +} diff --git a/src/main/kotlin/racingcar/service/StringTokenizer.kt b/src/main/kotlin/racingcar/service/StringTokenizer.kt new file mode 100644 index 0000000..632ade5 --- /dev/null +++ b/src/main/kotlin/racingcar/service/StringTokenizer.kt @@ -0,0 +1,29 @@ +package racingcar.service + +import racingcar.type.ExpressionRegex +import racingcar.vo.RawToken + +/** + * 문자열로 된 표현식을 토큰 단위로 분리한다. + */ +interface StringTokenizer { + fun parse(expression: String): List +} + +class RegexStringTokenizer : StringTokenizer { + override fun parse(expression: String): List { + val trimmedExpression: String = expression.replace(WHITESPACE_REGEX, "") + + if (trimmedExpression.matches(ExpressionRegex.EXPRESSION.regex) == false) { + throw IllegalArgumentException("유효하지 않은 입력입니다.") + } + + return ExpressionRegex.TOKEN.regex.findAll(input = trimmedExpression) + .map { RawToken(it.value) } + .toList() + } + + companion object { + private val WHITESPACE_REGEX: Regex = Regex("\\s") + } +} diff --git a/src/main/kotlin/racingcar/service/Token2ExpressionConvertor.kt b/src/main/kotlin/racingcar/service/Token2ExpressionConvertor.kt new file mode 100644 index 0000000..b40c8b3 --- /dev/null +++ b/src/main/kotlin/racingcar/service/Token2ExpressionConvertor.kt @@ -0,0 +1,61 @@ +package racingcar.service + +import racingcar.type.ExpressionRegex +import racingcar.type.ExpressionType +import racingcar.vo.Expression +import racingcar.vo.ExpressionToken +import racingcar.vo.NumberToken +import racingcar.vo.OperatorToken +import racingcar.vo.RawToken + +/** + * 토큰 리스트를 표현식으로 변환한다. + */ +interface Token2ExpressionConvertor { + fun convert(tokens: List): Expression +} + +class PostfixToken2ExpressionConvertor : Token2ExpressionConvertor { + override fun convert(tokens: List): Expression { + return tokens.map(::toExpressionToken) + .let { infixTokens: List -> convertToPostfix(infixTokens) } + .let { postfixTokens: List -> + Expression( + type = ExpressionType.POSTFIX, + tokens = postfixTokens, + ) + } + } + + private fun convertToPostfix(infixTokens: List): List { + val postfixExpression = mutableListOf() + val operatorStack = ArrayDeque() + + infixTokens.forEach { token -> + when (token) { + is NumberToken -> postfixExpression.add(token) + is OperatorToken -> { + if (operatorStack.isNotEmpty()) postfixExpression.add(operatorStack.removeLast()) + operatorStack.addLast(token) + } + } + } + + postfixExpression.addAll(operatorStack) + return postfixExpression + } + + private fun toExpressionToken(rawToken: RawToken): ExpressionToken { + if (rawToken.value.matches(ExpressionRegex.OPERATOR.regex)) { + return OperatorToken.fromRawTokenOrNull(rawToken) + ?: throw IllegalArgumentException("연산자로 변환할 수 없는 토큰입니다 (token=$rawToken)") + } + + if (rawToken.value.matches(ExpressionRegex.NUMBER.regex)) { + return NumberToken.fromRawTokenOrNull(rawToken) + ?: throw IllegalArgumentException("숫자로 변환 할 수 없는 토큰입니다 (token=$rawToken)") + } + + throw IllegalArgumentException("유효하지 않은 토큰입니다 (token=$rawToken)") + } +} diff --git a/src/main/kotlin/racingcar/type/ExpressionRegex.kt b/src/main/kotlin/racingcar/type/ExpressionRegex.kt new file mode 100644 index 0000000..b824adc --- /dev/null +++ b/src/main/kotlin/racingcar/type/ExpressionRegex.kt @@ -0,0 +1,8 @@ +package racingcar.type + +enum class ExpressionRegex(val regex: Regex) { + NUMBER(regex = "([1-9][0-9]*)".toRegex()), + OPERATOR(regex = "[+\\-*/]".toRegex()), + TOKEN(regex = "${NUMBER.regex}|${OPERATOR.regex}".toRegex()), + EXPRESSION(regex = "${NUMBER.regex}(${OPERATOR.regex}${NUMBER.regex})*".toRegex()), +} diff --git a/src/main/kotlin/racingcar/type/ExpressionType.kt b/src/main/kotlin/racingcar/type/ExpressionType.kt new file mode 100644 index 0000000..dd6a5b5 --- /dev/null +++ b/src/main/kotlin/racingcar/type/ExpressionType.kt @@ -0,0 +1,5 @@ +package racingcar.type + +enum class ExpressionType(val description: String) { + POSTFIX("후위표기식"), +} diff --git a/src/main/kotlin/racingcar/type/OperatorType.kt b/src/main/kotlin/racingcar/type/OperatorType.kt new file mode 100644 index 0000000..5fb9813 --- /dev/null +++ b/src/main/kotlin/racingcar/type/OperatorType.kt @@ -0,0 +1,8 @@ +package racingcar.type + +enum class OperatorType(val symbol: String) { + ADD("+"), + SUBTRACT("-"), + MULTIPLY("*"), + DIVIDE("/"), +} diff --git a/src/main/kotlin/racingcar/vo/Expression.kt b/src/main/kotlin/racingcar/vo/Expression.kt new file mode 100644 index 0000000..8eceb30 --- /dev/null +++ b/src/main/kotlin/racingcar/vo/Expression.kt @@ -0,0 +1,8 @@ +package racingcar.vo + +import racingcar.type.ExpressionType + +data class Expression( + val type: ExpressionType, + val tokens: List, +) diff --git a/src/main/kotlin/racingcar/vo/ExpressionToken.kt b/src/main/kotlin/racingcar/vo/ExpressionToken.kt new file mode 100644 index 0000000..4d3c76d --- /dev/null +++ b/src/main/kotlin/racingcar/vo/ExpressionToken.kt @@ -0,0 +1,28 @@ +package racingcar.vo + +import racingcar.type.OperatorType + +sealed interface ExpressionToken + +data class NumberToken(val value: Double) : ExpressionToken { + companion object { + fun fromRawTokenOrNull(raw: RawToken): NumberToken? { + return raw.value.toDoubleOrNull() + ?.let(::NumberToken) + } + } +} + +data class OperatorToken(val operatorType: OperatorType) : ExpressionToken { + companion object { + fun fromRawTokenOrNull(raw: RawToken): OperatorToken? { + return when (raw.value) { + "+" -> OperatorToken(OperatorType.ADD) + "-" -> OperatorToken(OperatorType.SUBTRACT) + "*" -> OperatorToken(OperatorType.MULTIPLY) + "/" -> OperatorToken(OperatorType.DIVIDE) + else -> null + } + } + } +} diff --git a/src/main/kotlin/racingcar/vo/RawToken.kt b/src/main/kotlin/racingcar/vo/RawToken.kt new file mode 100644 index 0000000..f7c47ac --- /dev/null +++ b/src/main/kotlin/racingcar/vo/RawToken.kt @@ -0,0 +1,3 @@ +package racingcar.vo + +data class RawToken(val value: String) diff --git a/src/test/kotlin/step1/PersonTest.kt b/src/test/kotlin/person/PersonTest.kt similarity index 99% rename from src/test/kotlin/step1/PersonTest.kt rename to src/test/kotlin/person/PersonTest.kt index 53b4a7a..074b1a7 100644 --- a/src/test/kotlin/step1/PersonTest.kt +++ b/src/test/kotlin/person/PersonTest.kt @@ -1,4 +1,4 @@ -package step1 +package person import io.kotest.assertions.assertSoftly import io.kotest.assertions.withClue diff --git a/src/test/kotlin/racingcar/application/CalculateUseCaseTest.kt b/src/test/kotlin/racingcar/application/CalculateUseCaseTest.kt new file mode 100644 index 0000000..879cecf --- /dev/null +++ b/src/test/kotlin/racingcar/application/CalculateUseCaseTest.kt @@ -0,0 +1,93 @@ +package racingcar.application + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import racingcar.service.PostfixExpressionCalculator +import racingcar.service.PostfixToken2ExpressionConvertor +import racingcar.service.RegexStringTokenizer + +class CalculateUseCaseTest : FreeSpec({ + + val calculateUseCase = CalculateUseCase( + stringTokenizer = RegexStringTokenizer(), + token2ExpressionConvertor = PostfixToken2ExpressionConvertor(), + expressionCalculator = PostfixExpressionCalculator(), + ) + + "기본적인 사칙연산을 수행할 수 있어야 한다" - { + "숫자 하나만 입력" { + val result = shouldNotThrowAny { calculateUseCase.calculate("5") } + + result shouldBe 5.0 + } + + "덧셈" { + val result = calculateUseCase.calculate("1+2") + + result shouldBe 3.0 + } + + "뺄셈" { + val result = calculateUseCase.calculate("3-1") + + result shouldBe 2.0 + } + + "곱셈" { + val result = calculateUseCase.calculate("3*3") + + result shouldBe 9.0 + } + + "나눗셈" { + val result = calculateUseCase.calculate("10/8") + + result shouldBe 1.25 + } + } + + "계산 순서는 사칙연산 규칙을 따르지 않고, 입력 값 순서대로 계산한다" - { + "덧셈과 뺼셈" { + val result = calculateUseCase.calculate("1-1+2+4-1+2+602+20+1") + + result shouldBe (1 - 1 + 2 + 4 - 1 + 2 + 602 + 20 + 1) + } + + "덧셈과 곱셈" { + val result = calculateUseCase.calculate("1+3123*36623") + + result shouldBe (1 + 3123) * 36623 + } + + "뺄셈과 나눗셈" { + val result = calculateUseCase.calculate("615121-42323/2") + + result shouldBe (615121 - 42323) / 2 + } + } + + "계산할 수 없는 경우네는 예외를 반환한다" - { + "빈 문자열 입력" { + shouldThrow { calculateUseCase.calculate("") } + shouldThrow { calculateUseCase.calculate(" ") } + } + + "유효하지 않은 문자열 입력" { + shouldThrow { calculateUseCase.calculate("안녕하세요") } + shouldThrow { calculateUseCase.calculate("3더하기3") } + } + + "잘못된 수식 입력" { + shouldThrow { calculateUseCase.calculate("0++12-1") } + shouldThrow { calculateUseCase.calculate("--32-1") } + shouldThrow { calculateUseCase.calculate("1+1--1") } + shouldThrow { calculateUseCase.calculate("+") } + } + + "숫자를 0으로 나눔" { + shouldThrow { calculateUseCase.calculate("1+1/0") } + } + } +})