Skip to content

Commit

Permalink
feat: step2 문자열 계산기 구현 (#26)
Browse files Browse the repository at this point in the history
* feat: step2 문자열 계산기 구현

* feat: [step2] 패키지 명 변경

* feat: [step2] calculator 리팩토링 및 의미있는 이름으로 클래스명 변경
  • Loading branch information
JiwonDev authored Aug 21, 2023
1 parent 69ae23e commit d65fc0f
Show file tree
Hide file tree
Showing 14 changed files with 335 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package step1
package person

data class Person(
val name: String,
Expand Down
4 changes: 4 additions & 0 deletions src/main/kotlin/racingcar/Step2Application.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package racingcar

fun main(args: Array<String>) {
}
24 changes: 24 additions & 0 deletions src/main/kotlin/racingcar/application/CalculateUseCase.kt
Original file line number Diff line number Diff line change
@@ -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<RawToken> = stringTokenizer.parse(expression = stringExpression)

val expression: Expression = token2ExpressionConvertor.convert(tokens = tokens)

require(expressionCalculator.isCalculable(expression)) { "계산할 수 없는 표현식 타입입니다." }
return expressionCalculator.calculate(expression = expression)
}
}
62 changes: 62 additions & 0 deletions src/main/kotlin/racingcar/service/ExpressionCalculator.kt
Original file line number Diff line number Diff line change
@@ -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<ExpressionToken> = 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<ExpressionToken>.calculateToken(token: ExpressionToken): ArrayDeque<ExpressionToken> {
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
}
}
}
}
29 changes: 29 additions & 0 deletions src/main/kotlin/racingcar/service/StringTokenizer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package racingcar.service

import racingcar.type.ExpressionRegex
import racingcar.vo.RawToken

/**
* 문자열로 된 표현식을 토큰 단위로 분리한다.
*/
interface StringTokenizer {
fun parse(expression: String): List<RawToken>
}

class RegexStringTokenizer : StringTokenizer {
override fun parse(expression: String): List<RawToken> {
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")
}
}
61 changes: 61 additions & 0 deletions src/main/kotlin/racingcar/service/Token2ExpressionConvertor.kt
Original file line number Diff line number Diff line change
@@ -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<RawToken>): Expression
}

class PostfixToken2ExpressionConvertor : Token2ExpressionConvertor {
override fun convert(tokens: List<RawToken>): Expression {
return tokens.map(::toExpressionToken)
.let { infixTokens: List<ExpressionToken> -> convertToPostfix(infixTokens) }
.let { postfixTokens: List<ExpressionToken> ->
Expression(
type = ExpressionType.POSTFIX,
tokens = postfixTokens,
)
}
}

private fun convertToPostfix(infixTokens: List<ExpressionToken>): List<ExpressionToken> {
val postfixExpression = mutableListOf<ExpressionToken>()
val operatorStack = ArrayDeque<ExpressionToken>()

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)")
}
}
8 changes: 8 additions & 0 deletions src/main/kotlin/racingcar/type/ExpressionRegex.kt
Original file line number Diff line number Diff line change
@@ -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()),
}
5 changes: 5 additions & 0 deletions src/main/kotlin/racingcar/type/ExpressionType.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package racingcar.type

enum class ExpressionType(val description: String) {
POSTFIX("후위표기식"),
}
8 changes: 8 additions & 0 deletions src/main/kotlin/racingcar/type/OperatorType.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package racingcar.type

enum class OperatorType(val symbol: String) {
ADD("+"),
SUBTRACT("-"),
MULTIPLY("*"),
DIVIDE("/"),
}
8 changes: 8 additions & 0 deletions src/main/kotlin/racingcar/vo/Expression.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package racingcar.vo

import racingcar.type.ExpressionType

data class Expression(
val type: ExpressionType,
val tokens: List<ExpressionToken>,
)
28 changes: 28 additions & 0 deletions src/main/kotlin/racingcar/vo/ExpressionToken.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
3 changes: 3 additions & 0 deletions src/main/kotlin/racingcar/vo/RawToken.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package racingcar.vo

data class RawToken(val value: String)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package step1
package person

import io.kotest.assertions.assertSoftly
import io.kotest.assertions.withClue
Expand Down
93 changes: 93 additions & 0 deletions src/test/kotlin/racingcar/application/CalculateUseCaseTest.kt
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> { calculateUseCase.calculate("") }
shouldThrow<IllegalArgumentException> { calculateUseCase.calculate(" ") }
}

"유효하지 않은 문자열 입력" {
shouldThrow<IllegalArgumentException> { calculateUseCase.calculate("안녕하세요") }
shouldThrow<IllegalArgumentException> { calculateUseCase.calculate("3더하기3") }
}

"잘못된 수식 입력" {
shouldThrow<IllegalArgumentException> { calculateUseCase.calculate("0++12-1") }
shouldThrow<IllegalArgumentException> { calculateUseCase.calculate("--32-1") }
shouldThrow<IllegalArgumentException> { calculateUseCase.calculate("1+1--1") }
shouldThrow<IllegalArgumentException> { calculateUseCase.calculate("+") }
}

"숫자를 0으로 나눔" {
shouldThrow<IllegalArgumentException> { calculateUseCase.calculate("1+1/0") }
}
}
})

0 comments on commit d65fc0f

Please sign in to comment.