GUI 계산기 (후위표기법)

개요

대학시절 간단한 계산기를 C++ GUI로 구현하다 생각보다 너무 어려워 🥵 구현하지 못한 경험이 있습니다.

그 때 목표는 아래 윈도우 계산기와 비슷하게(사칙연산만) 구현하는 것이었죠.

 

가장 큰 실패의 원인은 중위(Infix)표현식, 후위(Postfix)표현식이라는 개념을 몰랐기 때문입니다.

최근 편입 준비중인 컴공과 대학생과 수업하며 이 문제가 지방국립대 컴공과 편입시험문제로 출제된 것을 확인한 적이 있습니다.

그럼 왜 계산기 구현이 생각보다 여려운 주제인지 살펴보겠습니다.

 

중위(Infix) 표현식이란?

평소 우리가 사용하는 수식표기방식입니다.

예) 2 + 3 * 4

연산자 (+, -, *, / 등) 가 피연산자 (숫자) 사이에 위치합니다

 

후위(Postfix) 표현식이란?

컴퓨터가 사용하는 수식표기방식입니다.

예) 2 3 4 * +

연산자가 피연산자 뒤에 위치합니다. 처음보면 좀 당황스럽습니다.

(Stack : Last In First Out 자료구조로 구현)

 

2+3*4, 중위식이 컴퓨터에게 어려운 이유

  • 3*4부터 먼저 계산
  • 그 결과(12) 에 2를 더하기 답 14

문제 1. 컴퓨터가 마음속으로 수식의 우선순위를 기억 X.

문제 2. 괄호나 연산자의 우선순위를 관리해야 할 필요. 

 

컴퓨터는 후위식이 쉽다 

위에서 우리가 사용하는 중위표현식이 컴퓨터에게 어려운 이유를 설명드렸으나, 우리는 인간이므로 잘 이해되지 않으리라 생각합니다.

가령, 기계입장에서 "2 3 4 * +"라는 후위식이 왜 "2+3*4"보다 이해하기 쉬운지 말이죠.

일단 아래에 그 변환과정을 적어 두었으므로 다 읽고 나면 컴퓨터의 입장이 이해되리라 생각합니다. 

 

중위식에서 후위식변환

중위식 '2 + 3 * 4' 를 후위식 '2 3 4 * +' 로 바꾸는 방법입니다.

(머리속에 "2 + 3 * 4" 라는 글자의 왼쪽 2부터 시작한다 상상)

 

1. 수식의 왼쪽에서 오른쪽으로 이동.  

2. 숫자 2를 만나서 저장소 저장.

  • 저장소 : 2
  • 스택 : 비어있음 

3. '+' 연산자를 만나면 연산자는 스택저장.

  • 저장소 : 2
  • 스택 : + (최초 빈 스택이므로 그냥 저장)     

4. 숫자 3 만나서 저장소 저장

  • 저장소 : 2 3
  • 스택 : + 

5. '*' 연산자 만남, 연산자 스택저장.

  • 저장소 : 2 3
  • 스택 : + *

  • 위에서 * 연산자가 + 연산자보다 우선순위↑, 스택 저장(Push)
  • 아니면 스택에서 이전 연산자 꺼내 저장소 저장 후, 새연산자는 스택저장(Push)

6. 마지막 숫자 4를 만나면 저장소 저장.

  • 저장소 : 2 3 4
  • 스택 : + *

7. 모든 식이 끝나면 스택이 비워질 때 까지 스택에서 연산자 꺼내 저장소 저장.

  • 저장소 : 2 3 4 *
  • 스택 : +

8. 아직 스택에 남은 연산자가 있으므로 반복.

  • 저장소 : 2 3 4 * +
  • 스택 : 비어있음 

9. 최종 "2 3 4 * +"라는 후위식 완성.

 

이해되지 않는다면 처음부터 설명을 종이위에 적어가며 다시 진행해 보세요.

만약 "2 * 3 + 4" 라면 결과 후위식은 "2 3 * 4 +" 입니다. 

  

후위식 계산 방법

위에서 설명한 과정이 "2 + 3 * 4" 를 "2 3 4 * +" 로 변환하는 과정이라면, 

이제 컴퓨터가 이해하기 좋게 후위식으로 변환된 "2 3 4 * +" 를 계산해 14라는 답을 생성하는 과정입니다.


후위식 : "2 3 4 * +" 계산

1. 숫자 2 스택에 저장

  • 스택 : 2

2. 숫자 3 스택에 저장 (3이 스택의 위(Top) 임)

  • 스택 : 2 3

3. 숫자 4 스택에 저장

  • 스택 : 2 3 4

4. 연산자 '*'을 만남, 스택 Top에서 2개 꺼냄(Pop), 후 연산

  • 스택에서 4, 3을 꺼내 연산 : 3 * 4 = 12  
  •  스택 : 2 남음

5. 연산 결과 12는 다시 스택 저장

  • 스택 : 2 12

6. 연산자 '+' 만남, 스택 Top에서 2개 꺼냄(Pop), 후 연산

  • 스택에서 12, 2를 꺼내 연산 : 2 + 12 = 14
  • 스택 : 비어있음 

7. 연산 결과 14는 다시 스택 저장

  • 스택 : 14 

8. 연산자가 더 이상 없으므로 종료, 최종 스택 Pop

  • 답은 14
  • 스택 : 비어있음 

 

이번에는 그림으로 그려서 표현해 보겠습니다.

[후위식 계산과정]

 

구현

이론적인 과정이 이해된다면 코드로 구성해봐야겠죠.

Python 과 PyQt6로 구성한 계산기입니다.

괄호의 구현이나 음수, 소수점의 표현등은 위에서 설명하기 복잡해 코드로만 구현해 두었습니다.  

[내계산기 vs 윈도우 11 계산기]
 

앱을 실행한 모습입니다.


 

소스코드 

2개의 소스코드 파일로 구성되어 있으며, 아래 링크의 Qt designer의 UI 파일을 다운받아, 코드와 같은 곳에 두고 실행하면 됩니다.

UI 파일 링크 : calc.ui

main.py

from PyQt6.QtWidgets import QApplication, QWidget
from PyQt6.uic import loadUi
import sys
from calc import Calc

class Window(QWidget):

    def __init__(self):
        super().__init__()
        loadUi('calc.ui', self)
        self.setWindowTitle('Ocean Coding School')
        self.initUi()

    def initUi(self):
        self.nums = [self.pb_0, self.pb_1, self.pb_2, self.pb_3, self.pb_4,
                     self.pb_5,self.pb_6, self.pb_7, self.pb_8, self.pb_9, self.pb_dot]
        
        self.ops  = [self.pb_plus, self.pb_minus, self.pb_mul, self.pb_div]
        self.bk   = [self.pb_open, self.pb_close]

        # signal for numbers
        for pb in self.nums:
            pb.clicked.connect(self.onNumbers)

        # signal for operators
        for pb in self.ops:
            pb.clicked.connect(self.onOperators)     

        # signal for bracket
        for pb in self.bk:
            pb.clicked.connect(self.onBracket)

        # other signals
        self.pb_back.clicked.connect(self.onBack)
        self.pb_eq.clicked.connect(self.onEqual)
        self.pb_clear.clicked.connect(self.onClear)

    def writeExpression(self, sender):
        s = sender.text()
        exp = self.le.text()

        if not exp:
            # 처음 입력 규칙, 시작X
            if s in "+*/)":
                return
            if s == '.':
                return

        # 연산자 중복 방지, [-1:] 은 빈문자열도 오류처리 O
        if exp[-1:] in "+-*/" and s in "+-*/":
            # 단, 음수는 허용
            if s == '-' and exp[-1:] in "*/(" or exp == '':
                pass
            else:
                return

        # 닫는 괄호 앞에는 숫자나 )만 허용
        if s == ")" and exp[-1:] not in "0123456789)":
            return

        # 여는 괄호 앞에는 연산자나 시작이어야 함
        if s == "(" and exp[-1:] not in "+-*/(" and exp:
            return
        
        # dot 처리
        if s == '.':
            # 직전에 연산자, 괄호가 온 경우 dot 시작 불가
            if exp[-1:] in "+-*/()":
                return
            # 같은 숫자 내에서 dot 중복 방지(뒤에서부터, 즉 최근숫자만 체크)            
            num = ""
            for ch in reversed(exp):
                if ch in '0123456789.':
                    num = ch + num
                else:
                    break
            if '.' in num:
                return

        self.le.setText(exp + s)
    
    def onNumbers(self):
        sender = self.sender()        
        self.writeExpression(sender)

    def onOperators(self):
        sender = self.sender()
        self.writeExpression(sender)        

    def onBracket(self):
        sender = self.sender()
        self.writeExpression(sender)

    def onBack(self):
        exp = self.le.text()
        if exp:
            self.le.setText(exp[:-1])

    def onClear(self):
        self.le.clear()

    def onEqual(self):
        exp = self.le.text()
        if exp:
            calc = Calc(exp)
            result = calc.calcPostfix()
            
            if result:
                self.le.setText(f'{exp} = {result}')


if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = Window()
    w.show()
    sys.exit(app.exec())  


calc.py

class Calc:

    def __init__(self, exp):
        self.infix = exp   

    def priority(self, op):
        if op == '^':
            return 3
        elif op=='*' or op=='/':
            return 2
        elif op=='+' or op=='-':
            return 1
        else:
            return -1
        
    def parsing(self):        
        if not self.infix:
            return []
        
        s = 0        
        exp = []   
        i = 0
        while i < len(self.infix):
            c = self.infix[i]

            if c in '+-*/^()':
                # 음수처리
                if c=='-' and (i==0 or self.infix[i-1] in '+-*/^('):
                    j = i+1
                    while j < len(self.infix) and (self.infix[j].isdigit() or self.infix[j]=='.'):
                        j+=1
                    exp.append(self.infix[i:j])
                    i = j
                    s = i
                    continue
                if s!=i:
                    exp.append(self.infix[s:i])
                exp.append(c)
                s = i+1
            i+=1

        if s<len(self.infix):
            exp.append(self.infix[s:])
        return exp
        
    def infixToPostfix(self):
        lst = self.parsing()        
        postfix = []
        stack = []

        for c in lst:
            if self.isNumber(c):
                postfix.append(c)
            elif c=='(':
                stack.append(c)
            elif c==')':
                isOpen = False
                while stack:
                    top = stack.pop()
                    if top=='(':
                        isOpen=True
                        break
                    postfix.append(top)       
                if not isOpen:
                    print('Mismatched parentheses.')
                    return 
                
            elif c in '+-*/^':
                # '^'연산자 우측결합처리
                while stack and ((c != '^' and self.priority(c) <= self.priority(stack[-1])) or
                                 (c == '^' and self.priority(c) < self.priority(stack[-1]))):
                    postfix.append(stack.pop())
                stack.append(c)            

        while stack:
            top = stack.pop()
            if top == '(':
                print('Mismatched parentheses.')
                return 
            postfix.append(top)

        #print(postfix)
        return postfix

    def calcPostfix(self):
        postfix = self.infixToPostfix()
        stack = []

        for c in postfix:
            if self.isNumber(c):
                stack.append( self.strToNumber(c) )
            else:
                if len(stack)<2:
                    print('Not enough operands')
                    return
                a = stack.pop()
                b = stack.pop()

                if c=='+':
                    stack.append(b+a)
                elif c=='-':
                    stack.append(b-a)
                elif c=='*':
                    stack.append(b*a)
                elif c=='/':
                    if a==0:
                        print('Zero division error.')
                        return
                    stack.append(b/a)
                elif c=='^':
                    stack.append(b**a)

        return stack.pop()
    
    def isNumber(self, s):
        if not s:
            return False
        if s[0] == '-':
            s = s[1:] 
        return s.replace('.', '', 1).isdigit()

    def strToNumber(self, s):
        if '.' in s:
            return float(s)
        else:
            return int(s)


개발환경 

  • Windows 11 Pro, VS code
  • Python 3.12
  • PyQt6 6.9

 

이상으로 모든 설명을 마칩니다.

감사합니다. 

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

파이썬을 활용한 PID 제어기 GUI 구현

PyQt5 기반 동영상 플레이어앱 만들기