C# 5.2 · 5부. 실전 프로젝트

계산기 만들기

식을 읽어 셈합니다.

예상 학습 시간 25분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 실전
개념 설명

규칙을 코드로 옮깁니다

"1 + 2 * 3" 은 글자입니다. 여기서 7을 얻으려면 두 가지를 해야 합니다. 글자를 숫자와 기호로 나누고, 곱하기를 먼저 한다는 규칙을 지켜야 합니다.

if 로 하려 들면 곧 막힙니다. 곱하기를 먼저 찾아 셈하고 돌아오는 식으로 적을 수는 있는데, 괄호가 나오면 그 안을 먼저 해야 하고 그 안에 또 괄호가 있을 수 있습니다.

그래서 규칙을 문장으로 적는 것부터 합니다. 셋이면 됩니다.

식  = 항 (('+' | '-') 항)*
항  = 인자 (('*' | '/') 인자)*
인자 = 숫자 | '(' 식 ')' | '-' 인자

이 셋이 그대로 메서드 셋이 됩니다. 우선순위는 따로 처리하지 않습니다. 을 부르고 인자 를 부르는 순서 자체가 곱하기를 먼저 하게 만듭니다.

괄호도 저절로 풀립니다. 인자 가 다시 을 부르기 때문입니다. 이것을 재귀 하강이라고 합니다.

최소 예제 · 편집 가능

돌아가는 계산기

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
using System.Globalization;

string[] tests = [
    "1 + 2 * 3", "(1 + 2) * 3", "10 / 4", "-3 + 5",
    "2 * (3 + 4) - 10 / 5", "10 - 3 - 2", "0.1 + 0.2",
    "1 / 0", "1 + ", "(1 + 2", "1 & 2",
];

foreach (var t in tests) {
    try { Console.WriteLine($"{t,-22} = {Calculator.Eval(t)}"); }
    catch (Exception ex) when (ex is FormatException or DivideByZeroException) {
        Console.WriteLine($"{t,-22} ! {ex.Message}");
    }
}

static class Calculator {
    public static decimal Eval(string text) {
        var p = new Parser(text);
        var value = p.Expression();
        p.ExpectEnd();
        return value;
    }
}

// 식을 왼쪽부터 한 글자씩 지나가며 셈합니다.
ref struct Parser(ReadOnlySpan<char> text) {
    private readonly ReadOnlySpan<char> _text = text;
    private int _i = 0;

    // 식 = 항 (('+' | '-') 항)*
    public decimal Expression() {
        var left = Term();

        while (true) {
            if (Take('+')) left += Term();
            else if (Take('-')) left -= Term();
            else return left;
        }
    }

    // 항 = 인자 (('*' | '/') 인자)*
    private decimal Term() {
        var left = Factor();

        while (true) {
            if (Take('*')) left *= Factor();
            else if (Take('/')) {
                var right = Factor();
                if (right == 0) throw new DivideByZeroException("0 으로 나눌 수 없습니다.");
                left /= right;
            }
            else return left;
        }
    }

    // 인자 = 숫자 | '(' 식 ')' | '-' 인자
    private decimal Factor() {
        if (Take('-')) return -Factor();

        if (Take('(')) {
            var inner = Expression();
            if (!Take(')')) throw new FormatException("닫는 괄호가 없습니다.");
            return inner;
        }

        return Number();
    }

    private decimal Number() {
        Skip();
        var start = _i;
        while (_i < _text.Length && (char.IsDigit(_text[_i]) || _text[_i] == '.')) _i++;

        if (start == _i)
            throw new FormatException($"숫자가 와야 할 자리입니다({_i + 1}번째 글자).");

        return decimal.Parse(_text[start.._i], CultureInfo.InvariantCulture);
    }

    // 다음 글자가 c 면 지나가고 true 를 돌려줍니다.
    private bool Take(char c) {
        Skip();
        if (_i >= _text.Length || _text[_i] != c) return false;
        _i++;
        return true;
    }

    private void Skip() { while (_i < _text.Length && _text[_i] == ' ') _i++; }

    public void ExpectEnd() {
        Skip();
        if (_i < _text.Length)
            throw new FormatException($"읽을 수 없는 글자입니다('{_text[_i]}').");
    }
}
출력
1 + 2 * 3 = 7 (1 + 2) * 3 = 9 10 / 4 = 2.5 -3 + 5 = 2 2 * (3 + 4) - 10 / 5 = 12 10 - 3 - 2 = 5 0.1 + 0.2 = 0.3 1 / 0 ! 0 으로 나눌 수 없습니다. 1 + ! 숫자가 와야 할 자리입니다(5번째 글자). (1 + 2 ! 닫는 괄호가 없습니다. 1 & 2 ! 읽을 수 없는 글자입니다('&').

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

우선순위를 다루는 코드가 한 줄도 없습니다. 그런데 곱하기가 먼저 되었고 괄호도 풀렸습니다. 어떻게 그렇게 되는지가 아래의 이야기입니다.

상세 사용법

부르는 순서가 우선순위입니다

1 + 2 * 3 을 넣으면 이렇게 갑니다.

Expression()          1 을 얻으려고 Term() 을 부릅니다
  Term()              1 을 얻으려고 Factor() 를 부릅니다
    Factor()  → 1
  Term()              다음이 '*' 이므로 Factor() 를 또 부릅니다… 가 아닙니다
                      다음은 '+' 라 Term() 은 1 만 돌려주고 끝납니다
Expression()          '+' 를 지나고 오른쪽 Term() 을 부릅니다
  Term()              2 를 얻고, 다음이 '*' 이므로 계속합니다
    Factor()  → 3     2 * 3 = 6 을 돌려줍니다
Expression()          1 + 6 = 7

Term 이 곱하기를 다 끝내고 나서야 Expression 으로 돌아갑니다. 곱하기가 더 아래에 있으므로 먼저 끝나는 것이고, 그것이 우선순위입니다.

우선순위를 하나 더 넣고 싶으면 단계를 하나 더 만들면 됩니다. 거듭제곱을 곱하기보다 먼저 하게 하려면 Factor 아래에 한 단계를 둡니다.

괄호는 되돌아가는 것뿐

Factor 가 여는 괄호를 만나면 Expression 을 처음부터 다시 부릅니다.

if (Take('(')) {
    var inner = Expression();
    if (!Take(')')) throw new FormatException("닫는 괄호가 없습니다.");
    return inner;
}

몇 겹이든 상관없습니다. 안쪽 괄호가 다시 Factor 를 거쳐 Expression 을 부르기 때문입니다. 겹치는 깊이를 세는 코드가 필요 없습니다.

닫는 괄호를 여기서 확인하는 것이 중요합니다. 열었으면 그 자리에서 닫히는지 보아야, (1 + 2 가 조용히 3으로 셈되지 않습니다.

빼기는 왼쪽부터

10 - 3 - 2 는 5입니다. 9가 아닙니다. while 로 왼쪽부터 접어 나가기 때문입니다.

var left = Term();          // 10

while (true) {
    if (Take('-')) left -= Term();   // 10 - 3 = 7,  그다음 7 - 2 = 5
    …
}

재귀로 적으면 틀립니다. 오른쪽을 Expression() 으로 부르면 10 - (3 - 2) 가 되어 9가 나옵니다. 더하기와 곱하기는 결과가 같아 넘어가지만, 빼기와 나누기에서 드러납니다.

while 로 — 왼쪽부터
while (Take('-'))
    left -= Term();

// (10 - 3) - 2 = 5
재귀로 — 오른쪽부터
if (Take('-'))
    return left - Expression();

// 10 - (3 - 2) = 9   틀립니다

돈을 다룬다면 decimal

5.1에서 금액에 double 을 사용하지 말라고 했습니다. 계산기에서 그것이 그대로 보입니다.

Console.WriteLine(0.1 + 0.2);          // 0.30000000000000004
Console.WriteLine(0.1 + 0.2 == 0.3);    // False

Console.WriteLine(0.1m + 0.2m);        // 0.3
Console.WriteLine(0.1m + 0.2m == 0.3m);  // True

double2진 소수라 0.1을 정확히 담지 못합니다. 화면에 0.3으로 보여도 값은 조금 다르고, 비교하면 어긋납니다. 사람이 10진으로 적은 것을 다룬다면 decimal 입니다.

대신 느립니다. 물리 계산이나 그래픽처럼 자릿수보다 속도가 중요한 자리에서는 double 이 맞습니다.

어디서 틀렸는지 말해 주기

"잘못된 식입니다" 만으로는 고칠 수 없습니다. 무엇이 왜 안 되는지를 셋으로 나누어 적었습니다.

어긋난 것어디서 잡는가메시지
숫자가 없음Number숫자가 와야 할 자리입니다(5번째 글자)
괄호가 안 닫힘Factor닫는 괄호가 없습니다
모르는 글자ExpectEnd읽을 수 없는 글자입니다('&')

ExpectEnd 가 없으면 어떻게 되는지 보십시오. "1 & 2" 에서 Expression1까지만 읽고 만족해서 돌아갑니다. 뒤에 무엇이 남았는지 아무도 보지 않으면 조용히 1이 답으로 나갑니다.

public static decimal Eval(string text) {
    var p = new Parser(text);
    var value = p.Expression();
    p.ExpectEnd();          // 이 줄이 없으면 "1 & 2" 가 1 이 됩니다
    return value;
}

다 읽었는지 확인하는 것이 파싱의 마지막 단계입니다. 빠뜨리기 쉽고, 빠뜨리면 틀린 답이 오류 없이 나갑니다.

ref struct 인 까닭

ParserReadOnlySpan<char> 를 담으므로 ref struct 여야 합니다(4.8). 식을 자를 때마다 새 문자열이 생기지 않습니다.

ref struct Parser(ReadOnlySpan<char> text) {
    private readonly ReadOnlySpan<char> _text = text;
    private int _i = 0;

여기서는 string 으로 두어도 됩니다. 식 하나는 짧고 사람이 한 번 누를 때마다 한 번 도는 자리라, 만드는 것을 아껴 봐야 값이 없습니다. 4.8에서 본 대로 먼저 재고 나서 고칠 자리입니다.

그래도 이렇게 적은 것은 이 모양이 자연스럽기 때문입니다. 자리를 하나씩 지나가며 읽는 코드는 잘라 담을 일이 없고, 그럴 때 Span 이 오히려 읽기 좋습니다.

실습 문제

직접 해보기

1. 거듭제곱 넣기 난이도 하

^ 를 넣어보세요. 곱하기보다 먼저 해야 하고, 2 ^ 3 ^ 2오른쪽부터라 512입니다(2 ^ (3 ^ 2)).

먼저 해야 하니 Factor 보다 아래에 단계를 하나 더 둡니다. 오른쪽부터이므로 while 이 아니라 자기 자신을 부릅니다.
// 1) 항이 인자 대신 거듭제곱을 부르게 합니다 private decimal Term() { var left = Power(); // Factor() 였던 자리 while (true) { if (Take('*')) left *= Power(); else if (Take('/')) { … } else return left; } } // 2) 거듭제곱 = 인자 ('^' 거듭제곱)? // 오른쪽에서 자기 자신을 부르면 오른쪽부터가 됩니다. private decimal Power() { var left = Factor(); if (!Take('^')) return left; return (decimal)Math.Pow((double)left, (double)Power()); } // 2 ^ 3 ^ 2 → 2 ^ (3 ^ 2) → 2 ^ 9 → 512 // Math.Pow 는 double 만 받습니다. 지수는 자릿수가 문제되는 자리가 // 아니라 이대로 두어도 되지만, 정확해야 한다면 정수 지수만 받고 // 곱셈을 되풀이하는 편이 맞습니다.
2. 어디서 틀렸는지 짚어 주기 난이도 중

지금은 "1 + " 만 자리를 알려 주고 나머지는 알려 주지 않습니다. 모든 오류가 몇 번째 글자인지 말하도록 고치고, 그 자리를 가리키는 화살표까지 그려보세요.

자리를 메시지에 섞어 적으면 꺼내기 어렵습니다. 예외에 자리를 담는 형식을 따로 만드는 편이 낫습니다. 3.7에서 본 사용자 정의 예외입니다.
// 자리를 담는 예외를 따로 둡니다(3.7). class SyntaxException(string message, int position) : FormatException(message) { public int Position { get; } = position; } // 던지는 자리마다 _i 를 함께 넘깁니다. throw new SyntaxException("숫자가 와야 합니다.", _i); throw new SyntaxException("닫는 괄호가 없습니다.", _i); throw new SyntaxException("읽을 수 없는 글자입니다.", _i); // 받는 쪽에서 그립니다. try { Console.WriteLine(Calculator.Eval(text)); } catch (SyntaxException ex) { Console.WriteLine(text); Console.WriteLine(new string(' ', ex.Position) + "^"); Console.WriteLine(ex.Message); } // 1 & 2 // ^ // 읽을 수 없는 글자입니다. // 자리를 메시지 안에 글자로 섞어 두면 받는 쪽이 다시 꺼내야 합니다. // 화면이 한국어가 아니게 되는 날 그 코드가 먼저 깨집니다. // _i 는 0부터이므로 사람에게 보일 때만 1을 더합니다. 화살표는 // 0부터인 그대로 사용합니다.
요약
  • 규칙을 문장으로 먼저 적습니다. 식·항·인자 셋이 메서드 셋이 됩니다.
  • 우선순위를 다루는 코드는 없습니다. 부르는 순서가 곧 우선순위입니다.
  • 괄호는 FactorExpression 을 다시 부르는 것뿐입니다. 깊이를 세지 않습니다.
  • 왼쪽부터 접으려면 while, 오른쪽부터면 자기 자신을 부릅니다.
  • 사람이 10진으로 적은 것은 decimal 입니다. double0.1 + 0.2 == 0.3 이 거짓입니다.
  • 다 읽었는지 확인하십시오. 빠뜨리면 "1 & 2" 가 오류 없이 1이 됩니다.
  • 오류에는 어디서 왜를 담습니다. 자리는 메시지에 섞지 말고 따로 담습니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.