240425 Kotlin 강의 1주차 - 계산기 강화(+후위 계산기)

노재원·2024년 4월 25일
0

내일배움캠프

목록 보기
26/90

아직 지급받은 강의를 다 들은 건 아닌데 팀원 분들이랑 진도를 좀 맞추고 있기도 하고 얼추 다 아는 내용이라지만 너무 성의없이 듣고 싶진 않아서 과제가 가능한 수준에 조금 더 듣고 계속 여러 버전의 계산기를 만들고 있다.


수식을 문자열로 받아 처리하는 계산기

  • Level 1에서 간단한 Class 껍데기와 사칙연산
  • Level 2에서 멤버 변수로 바꾸고 나머지도 추가하고 반복 계산 추가
  • Level 3에서 매개변수로 각 Operation별 인스턴스를 받아서 클래스 내에서 관리하기
  • Level 4에서 Interface(랑 Abstract)로 관계 맺고 함수 통일하기

...에 추가로 디테일 이거저거 사소한거 더해서 만드는 건 어제까지였고 오늘은 추가로 문자열로 입력 받는 계산기, 그리고 이게 생각보다 빨리 끝나서 자료구조는 영 젬병이지만 전/중/후위 표현식을 최근에 정보처리기사로도 접해봤으니 그 중 스택 구조로 주로 표현하는 후위 표현식 계산기까지 만들어보기로 했다.

긴 수식을 문자열로 받아 처리하기

실행 결과

Regex("""\d+|\S""").findAll(input).toList().map { it.value }.let { matchResults -> {}

일단 가독성 떨어지긴 하지만 이 확장판 기능들은 이미 구현 기능 거리가 많이 멀어져서 과제로 제출할 생각은 아니라서 대충 작성했다. 이럴 때도 꼼꼼하면 당연히 좋기야 하겠지만 클린 코드를 실천하던 실무 때랑 달리 약간 원시적이어도 빨리 처리를 해보고 싶다는 느낌같기도 하고..

어쨌든 정규표현식으로 숫자와 글자들만 걸러냈고 입력 값으로 받은 걸 value만 걸러내서 List로 처리해 사용하기로 했다. 일단 .findAll 로 모든 값을 처리했으니 MatchResult 에서 .next.range 를 쓸 필요도 없으니 값만 걸러내자는 취지에서 했다.

List로 전환한 이유는 .get을 써야해서 바꾼 건데 바꾸는 김에 원래 결과였던 Sequence랑 무슨 차이가 있는지 대충 조사해봤다.

// 출처 https://medium.com/@mook2_y2/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%9E%85%EB%AC%B8-%EC%8A%A4%ED%84%B0%EB%94%94-15-sequences-52cfca1805c8
fun m(i: Int): Int {
    print("m$i ")
    return i
}
fun f(i: Int): Boolean {
    print("f$i ")
    return i % 2 == 0
}

val list = listOf(1, 2, 3, 4)
// 1. Collection (m1 m2 m3 m4 f1 f2 f3 f4)
list.map(::m).filter(::f)
// 2. Sequence (m1 f1 m2 f2 m3 f3 m4 f4)
list.asSequence().map(::m).filter(::f).toList()

Sequence와 List의 처리 방식이 꽤 틀려서 차례차례 순회하는 List랑 달리 Sequence는 지연 평가 (Lazy Evaluation) 로 처리해서 요소를 다 올리는 게 아니라 하나씩 처리하고 필요할 때만 메모리에 로드해서 결과적으로 한 값마다 map, filter를 모두 적용하는 순서인 건 이번에 처음 알았다.

Sequence가 가지는 장점을 지금 내 머리로 할 수 있나 같은 건 모르겠고 차이는 얼추 알았으니 필요에 따라 다시 찾게 될 일이 올 것이다.

// 피연산자는 패스
if (index % 2 == 0) {
	return@forEachIndexed
}

val firstNum = result
val secondNum = matchResults[index + 1].toDouble()

쓰면서도 이게 맞나 싶었지만 입력만 정상이면 중위 표현식인 평범한 수식에서 문제가 생길 일은 없긴 하다. 현재 value가 연산자일 때 작동하므로 밑에서 when 으로 분기처리 해주고 결과만 뱉어주는 식으로 우선순위도 괄호처리도 안되지만 문자열로 입력받는 계산기는 쉽게 처리했다.

if (matchResults.count() < 3) { throw Exception("수가 적어 연산이 불가능합니다.") }
if (!matchResults.last().single().isDigit()) { throw Exception("수식이 완성되지 않았습니다.") }
throw Exception("${value}는 틀린 연산자입니다.")

이 계산 중 예외처리는 딱 3개 들어갔다. 상용 계산기에 비하면 예외가 훨씬 많겠지만 그래도 계산기스러운 면모는 갖춘 것 같다.

후위 표현식 이용해서 계산기 만들기

후위 계산기

후위 표현식 자료구조 참고 사이트

여태 계산기 만들어봤자 GUI로 만든 거라 사칙연산이니 우선순위니 생각 안했었는데 동기 분중에서 먼저 후위 표현식으로 계산기를 그려주신 분이 계시길래 나도 Python으로 짜여져 있는 자료구조 설명 보고 따라서 짜봤다.

Stack에 뭐 쌓을 일이 사실 실무 하면서는 아예 없었다고 해도 될 정도라 쓰는 느낌 자체가 생소할 뻔 했는데 그냥 Iterator를 통한 순회가 아니라 MutableList 가지고 돌리니까 이상하게 친숙한 느낌이다. 사실 나는 감성의 영역에서 거부감이 들던 게 아닐까?

/**
 * 중위 표현식이 담긴 List<String> 을 후위 표현식으로 바꾼 List<String> 으로 반환하는 계산용 함수
 * 자료구조 참고: https://chanos.tistory.com/entry/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%EC%8A%A4%ED%83%9D-%EC%98%88%EC%A0%9C-%EC%A4%91%EC%9C%84-%ED%91%9C%EA%B8%B0infix%EB%A5%BC-%ED%9B%84%EC%9C%84-%ED%91%9C%EA%B8%B0postfix%EB%A1%9C-%EB%B3%80%ED%99%98%ED%95%98%EA%B8%B0
 *
 * @return 중위 표기식을 후위 표기식으로 바꾼 List
 */
private fun List<String>.toPostFixList(): List<String> {
    // 후위 연산 저장중인 List, 최종적으로 반환됨
    val tempPostFixList = mutableListOf<String>()
    // 우선 순위에 맞춰서 관리중인 Stack
    val operatorList = mutableListOf<String>()

    if (this.count() < 3) throw IllegalArgumentException("연산 식이 부족합니다")

    // 숫자와 연산자를 적절하게 tempPostFixList, operatorList 에 저장함
    this.forEach { value ->
        when (value) {
            // 우선순위 높은 연산자는 그냥 바로 추가
            "*", "/", "%", "(" -> operatorList.add(value)
            // 우선순위가 떨어지는 연산자는
            // 1. 연산자 리스트가 비어있으면 바로 추가
            // 2. 연산자 리스트가 차있으면 처음 만나는 고우선순위 연산자를 후위 표기 리스트로 빼고 그 자리를 채움
            "+", "-" -> {
                if (operatorList.isEmpty()) operatorList.add(value)
                else {
                    if (operatorList.last().let { it == "*" || it == "/" || it == "%" }) {
                        tempPostFixList.add(operatorList.removeLast())
                    }
                    operatorList.add(value)
                }
            }
            // 괄호가 닫히면 열린 괄호를 찾을 때 까지의 연산자를 다 후위 표기 리스트로 넣어버림
            ")" -> {
                while (operatorList.last() != "(") {
                    tempPostFixList.add(operatorList.removeLast())
                }
                // 처리하고 남은 "(" 도 제거함
                operatorList.removeLast()
            }
            else -> tempPostFixList.add(value)
        }
    }
  
    // operatorList에 남아있던 마지막 연산자들을 추가해줌
    while (operatorList.isNotEmpty()) { tempPostFixList.add(operatorList.removeLast()) }

    return tempPostFixList
}

갑작스럽지만 새삼 정리한다고 블로그에 올리고 느낀 점이 붙여 넣을 때 정렬이 좀 많이 안된다. 그리고 TIL에 적어넣을 내용도 주석에 적는게 조금 더 보기 좋은 것 같긴 하다.

어쨌든 중위 표현식으로 된 연산 입력값들을 후위 표현식으로 바꾸는 과정이고 전적으로 검색 결과의 도움을 대부분 받았다. 특히 괄호 부분을 개념적인 변환 과정만 봐서는 이해가 안갔는데 괄호가 닫혔을 때의 처리 과정이 생각보다 단순했고 지속적으로 변하는 스택 역할의 operatorList 와 쌓기만 하고 빠지지 않아 실제 반환에 쓰는 tempPostFixList 의 용도 차이를 알고 나서는 작성 난이도가 꽤 낮아졌다.
(아마 보통 쓰는 Iterator 방식으로 하면 순회 추정할 때 실수했을 것 같다.)

/**
 * 후위 표현식을 계산합니다.
 *
 * @param postFixList: 후위 표현식으로 숫자, 연산자를 저장한 리스트
 * @return 후위 표현식을 계산 완료한 값
 */
private fun calculateWithPostFix(postFixList: List<String>): Double {
    val tempPostFixList = postFixList.toMutableList()
    val stackLikeForCalculate = mutableListOf<Double>()

    tempPostFixList.forEach {
        when (it) {
            "+", "-", "*", "/", "%" -> {
                val num1 = stackLikeForCalculate.removeLast()
                val num2 = stackLikeForCalculate.removeLast()

                stackLikeForCalculate.add(
                    when (it) {
                        "+" -> num1 + num2
                        /* "-" -> { ... } */
                        else -> throw Exception("연산자 처리 에러")
                    }
                )
            }
            else -> stackLikeForCalculate.add(it.toDouble())
        }
    }

    return stackLikeForCalculate.removeLast()
}

계산 방식은 후위표현식 바꾸는 과정에 비하면 아주 간단했다. 이번엔 MutableList의 removeLast()를 써서 Stack 스럽게 구현하긴 했지만 진짜 Iterator로도 한 번쯤은 코드 리딩을 해야할 것 같다. 그리고 조건도 아직 엉성하게 짜여져 있어서 좀 예쁘게도 해야할 것 같고..

Kotlin 스럽게 더욱 간결하게 처리할 방법이 여기저기 섞여있을 것 같은데 오늘은 일단 정상 작동하는 것에 만족하기로 했다. 문법도 그렇고 함수 분리도 조금 더 할 수 있을 것 같은데 내일 제출할 파일이 이것도 아니고 조금 여유를 가지기로 했다.

슬금슬금 돌아오는 기억

내일배움캠프를 계속 해보면서 느끼는 건 코딩도 머슬 메모리인 건지 4년의 경력 단절로 코딩 자체가 어색했던 사전캠프 시작할 당시의 나는 벌써 많이 지워진 것 같고 마인드셋이 완전 개발자 시절로 돌아간 느낌이 들기도 한다. 진짜 키보드를 무의식으로 치는 머슬 메모리는 아니겠지만 4년동안 안 쓰고 묵혀둔 지식들이 여전히 떠오르고 Swift처럼 치는 버릇도 나오고 참 재밌는 현상같다.

아직 개발에 갓 입문한 단계의 수준이지만 코드 리뷰든 특강을 듣든 배우지도 않은 기술 스택의 레퍼런스를 보고 회사에서 어깨 너머로 봤던 것 같은 유형의 구조라던가 하는 것들조차 묘하게 떠오르는게 그냥 나는 공부할 계기가 있었으면 복구가 가능한 수준만큼은 일을 했었나? 싶은 생각도 든다.

물론 본 목적인 '서버 이해하기'를 달성한 건 아니고 그냥 Kotlin 입문 단계 깔짝대면서 자아도취에 빠져 헛소리 하고 있을 확률이 크다. 아직 갈 길이 90일 언저리 남았는데 자신감이 벌써부터 솟으면 꺾일 때 또 번아웃을 맞을 수도 있으니 자중하자.


Git Convention

오늘 오후에는 매니저님이 직접 오셔서 Git Readme, Commit convention 같은 걸 보여주고 가셨다.

실무할 때 고민 안해본 건 아니고 나름의 작성 규칙을 갖고 하고 있지만 요즘은 마음이 널널하고 급한 일도 아니라서 흐지부지 되긴 했다.

나는 ${파일이름} ${코드설명} ${액션(생성, 추가, 삭제, 수정, 버그 픽스등)} 같은 느낌으로 조합해서 Level4.kt 과제용 추상화코드 추가 같은 느낌으로 메시지를 날렸었는데 매니저님이 보여주신 예제는 Emoji, Label성 텍스트 선표기, 명확한 커밋 메시지등 사실 매우 이상적일 정도로 확실한 답이었다.

내 스타일은 전적으로 다녔던 회사 메시지 영향을 받기도 했고 나는 혼자라서 PR 단위 관리도 한 적이 없으니 저 정도까지 해야하나.. 싶었지만 생각해보면 지금의 처지는 다시 취준생이 됐고 누군가에게 내 Repo를 실제로 많이 보여줘야 한다는 점에서 매우 달라지긴 했다.

어쨌든 이번 주는 이미 공친 것 같지만 Readme 작성과 Commit convention 통일에서 조금 신경을 더 쓰기로 했다. 변수라면 팀 프로젝트일 때 팀원들에게 이 정도쯤 합시다! 라고 과감하게 말할 수 있을 정도의 실행력도 없고 내가 먼저 못 지킬 수도 있으니 조율도 잘 해야겠다.


숫자 문자열과 영단어

네오와 프로도가 숫자놀이를 하고 있습니다. 네오가 프로도에게 숫자를 건넬 때 일부 자릿수를 영단어로 바꾼 카드를 건네주면 프로도는 원래 숫자를 찾는 게임입니다.

다음은 숫자의 일부 자릿수를 영단어로 바꾸는 예시입니다.

  • 1478 → "one4seveneight"
  • 234567 → "23four5six7"
  • 10203 → "1zerotwozero3"

이렇게 숫자의 일부 자릿수가 영단어로 바뀌어졌거나, 혹은 바뀌지 않고 그대로인 문자열 s가 매개변수로 주어집니다. s가 의미하는 원래 숫자를 return 하도록 solution 함수를 완성해주세요.

참고로 각 숫자에 대응되는 영단어는 다음 표와 같습니다.

숫자 영단어
0 zero
1 one
2 two
3 three
4 four
5 five
6 six
7 seven
8 eight
9 nine

문제 링크

fun solution(s: String): Int {
        val numberPatternMap = mapOf(
            "zero" to 0,
            "one" to 1,
            "two" to 2,
            "three" to 3,
            "four" to 4,
            "five" to 5,
            "six" to 6,
            "seven" to 7,
            "eight" to 8,
            "nine" to 9
        )
        
        var result = ""
        var stringForSearch = ""
        
        s.forEach { char ->
            if (char.isDigit()) result += char
            else {
                stringForSearch += char
                numberPatternMap.get(stringForSearch)?.let {
                    result += it
                    stringForSearch = ""
                }
            }
        }
        
        return result.toInt()
    }

보자마자 Map이 생각나서 패턴 매칭용 맵을 만들고 문자열 순회를 돌려서 매칭시키는 걸 반환하는 식으로 만들었다.

제출하기 전에 더 짧을 수 있지 않나 짱구를 굴려봤지만 구분없이 지어진 문자열에서 숫자를 영리하게 추출하는 방법에 대해 생각해보다가 찾으면 너무 싱거울 것 같아서 이대로 제출했고 이 고민은 복선이 되었다.

fun solution2(s: String): Int {
        var result = s
        val numberPatternMap = mapOf(
            "zero" to 0,
            "one" to 1,
            "two" to 2,
            "three" to 3,
            "four" to 4,
            "five" to 5,
            "six" to 6,
            "seven" to 7,
            "eight" to 8,
            "nine" to 9
        )
        
        numberPatternMap.forEach { result = result.replace(it.key, it.value.toString()) }
        
        return result.toInt()
    }

어떻게 사람이 실컷 써먹던 replace를 까먹을ㅋㅋ수가ㅋㅋㅋ

'이건 상당히 재미있는 알고리즘 문제군!' 이라는 생각이 내 머리 구조를 잘못 만든 것 같다. 왜 굳이 잘 마련된 내장함수를 안써먹고 문자열 순회 돌리며 매칭할 생각이 먼저 떠올랐는지 스스로도 납득이 잘 안된다.

어쨌든 빨간약을 먹자마자 Map 순회로 문자열의 값을 차례차례 수정하는 식으로 바꿨고 퍼포먼스도 좀 개선되었다.

겉에 보이는 시간이 예상보다 크게 차이난 건 아니긴 했지만 문제를 정리하자면

  1. 대부분의 경우에서 반복 횟수는 늘어났고 훨씬 늘어날 수 있음 (운좋으면 짧을 수도 있긴 함)
  2. 예시로 five가 5번 연달아 붙어 나와도 중복 패턴을 한 번에 처리하지 못함
  3. 의도가 어쨌건 가독성 구짐

이런 이유에서 반성을 좀 더 하고 쉽게쉽게 날로 먹으려고만 들던 실무 때의 마인드를 조금 가져와서 장착할 필요가 있어 보이는 시간이 됐다.


여담으로 가장 퍼포먼스가 빠른 건 다른 분이 제출하신 이 코드였다.

fun solution3(s: String): Int = s
    .replace("one", "1")
    .replace("two", "2")
    .replace("three", "3")
    .replace("four", "4")
    .replace("five", "5")
    .replace("six", "6")
    .replace("seven", "7")
    .replace("eight", "8")
    .replace("nine", "9")
    .replace("zero", "0")
    .toInt()

이게 바로 문제의 의도를 깨우친 신속 정확한 코드가 아닐까? 어쨌든 퍼포먼스는 제일 좋다.

0개의 댓글