【Day55】Python標準ライブラリだけで作るWindows電卓アプリ(tkinter+ast/基礎から丁寧に)

未分類

本稿では、Pythonの基礎だけでWindows向けの簡易電卓アプリを作ります。GUIは標準ライブラリのtkinter、四則演算の計算はast(抽象構文木)で式を解析して安全寄りに評価します。外部ライブラリは使いません。
「ウィンドウを開く」「ボタンを並べる」「イベント(クリックやキー入力)で関数を呼ぶ」といったGUIアプリの基礎を、できるだけ噛み砕いて説明します。最後にPyInstallerでEXE化して単体配布する手順も紹介します。


1. なぜtkinter?なぜ“基礎だけ”?

tkinterはPythonに同梱の標準GUIツールキットで、追加インストールなしに使えます。Windowsでも動作し(macOS/Linuxでも同様)、小規模なユーティリティや学習には最適です。tkinterはTcl/TkへのPythonインターフェイスで、各ウィジェット(ボタン・ラベル・入力欄など)はPythonのクラスとして提供されます。
GUIの配置には「geometry manager」を使い、本稿ではグリッド(grid)で電卓のボタンを盤面のように配置します。gridは行×列の考え方で、電卓のUIと相性が良い幾何管理手法です。(参考:tkinter公式、Tkの概念とgrid概要)※詳細は後掲の出典を参照

  • 標準装備:追加の依存がなく学習に集中できる
  • シンプル:少ないコード量で動く=読みやすい
  • 移植性:Windows/macOS/Linuxで同様に動く

「Pythonの基礎だけで」という条件は、逆に言えば「言語本体と標準機能を使って設計・分解・実装する練習」に最適です。電卓は、入力・表示・状態管理・演算というGUIアプリのコアをすべて含み、学習題材としてちょうど良いサイズ感です。


2. アプリ設計(最小にして実用)

今回の電卓は以下の要件を満たします。

  1. 表示:現在の式と結果を1行表示(右寄せ、等号で確定)
  2. 入力:数字・小数点・四則演算(+−×÷)、括弧、%(百分率)、±(符号反転)
  3. 編集:C(クリア)、⌫(バックスペース)
  4. 評価:式を安全寄りに評価(astで許可ノードだけを再帰評価)
  5. 操作:ボタン+キーボード(Enter==、Backspace=⌫、Esc=C など)

設計のポイントは「表示用の文字列」と「内部評価用の式」を分けることです。ボタンには見やすい記号(×、÷、−)を出しつつ、内部ではPython演算子(*/-)に正規化して保存・評価します。UIの見やすさと実装の簡潔さを両立できます。


3. tkinter超入門(必要最小の基礎)

  • ウィンドウ生成:root = tkinter.Tk() でルートウィンドウを作る。root.titleroot.geometryで見た目を調整。
  • ウィジェット:Label(表示)、Button(ボタン)、Entry(入力欄)など。今回の表示はEntryLabelでOK。右寄せやフォント設定はオプション引数で。
  • 配置:grid(row=i, column=j) で盤面に配置。行列のweightを設定するとリサイズ時に伸縮する。
  • イベント:ボタンのcommand=関数でクリック時に処理。root.bind('<Return>', func)でキー入力も拾える。

tkinterはPython標準のGUIで、TkのウィジェットをPythonクラスでラップしたものです。詳細は公式ドキュメント(tkinter・tk)やTkDocsのgridチュートリアルがまとまっています。(出典は末尾)


4. 数式評価を“安全寄り”にする考え方

ユーザー入力をそのままeval()するのは危険です。本稿ではastで式をパースし、許可したノード(演算子・数値・括弧・単項符号)だけを再帰的に評価します。こうすることで、関数呼び出しや属性アクセスなどを遮断し、四則演算+数値の範囲に制限できます。必要十分な範囲で「安全寄り」と言える実装です(完全なサンドボックスではない点は念のため)。


5. まずは完成コード(コメント付き)

以下を calculator_tk.py として保存してください。WordPressでは<pre><code class="language-python">で貼れば見やすく表示されます。

# Day55: 標準ライブラリだけで作るWindows電卓(tkinter + ast)
# ---------------------------------------------------------------
# 依存: Python 3.x(tkinterは標準同梱)
# ・GUI: tkinter
# ・式評価: ast(許可ノードのみ再帰評価)
# ・対応: +, -, *, /, //, %, **, 括弧, 単項+-, 小数点、C, ⌫, %, +/-
# ・操作: ボタン + キーボード(Enter, Backspace, Esc)
# ---------------------------------------------------------------

import tkinter as tk        # GUI本体(標準)
from tkinter import ttk     # 見た目の良いウィジェット群(標準)
import ast                  # 抽象構文木:evalの代わりに安全寄り評価
import operator as op

# 許可する二項演算子を辞書化(astノード -> Pythonの演算関数)
BIN_OPS = {
    ast.Add:  op.add,
    ast.Sub:  op.sub,
    ast.Mult: op.mul,
    ast.Div:  op.truediv,
    ast.FloorDiv: op.floordiv,
    ast.Mod:  op.mod,
    ast.Pow:  op.pow,
}

# 許可する単項演算子
UNARY_OPS = {
    ast.UAdd: lambda x: +x,
    ast.USub: lambda x: -x,
}

def safe_eval(expr: str) -> float:
    """astで式を解析し、許可ノードだけを再帰評価(四則演算+一部拡張)。"""
    # 空や不正末尾は弾く(例: "1+")
    if not expr or expr.strip() == "":
        return 0.0

    node = ast.parse(expr, mode="eval")  # 式モードでパース
    return _eval_ast(node.body)

def _eval_ast(node):
    """許可ノードのみを再帰評価。その他は例外。"""
    if isinstance(node, ast.BinOp) and type(node.op) in BIN_OPS:
        left = _eval_ast(node.left)
        right = _eval_ast(node.right)
        return BIN_OPS[type(node.op)](left, right)

    if isinstance(node, ast.UnaryOp) and type(node.op) in UNARY_OPS:
        operand = _eval_ast(node.operand)
        return UNARY_OPS[type(node.op)](operand)

    # Python 3.8+ 数値リテラルは Constant、古い版だと Num
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if hasattr(ast, "Num") and isinstance(node, ast.Num):  # 互換
        return node.n

    if isinstance(node, ast.Expression):
        return _eval_ast(node.body)

    if isinstance(node, ast.Expr):
        return _eval_ast(node.value)

    # 許可しないもの(関数呼び出し、名前参照など)は弾く
    raise ValueError("許可されていない式、または構文です。")

class CalculatorApp:
    """シンプル電卓本体。表示は人に優しく、内部はPython演算子で保持。"""

    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("電卓(tkinter版)")
        self.root.geometry("320x420")
        self.root.minsize(280, 380)

        # 表示用と内部用の式
        self.display_var = tk.StringVar(value="0")
        self.internal_expr = ""  # 内部評価用(*, /, - などに正規化)

        # スタイル(ttk)
        style = ttk.Style(self.root)
        style.theme_use(style.theme_use())  # 既定テーマ
        style.configure("Display.TEntry", font=("Segoe UI", 20))
        style.configure("Calc.TButton",  font=("Segoe UI", 14), padding=6)

        self._build_ui()
        self._bind_keys()

    def _build_ui(self):
        # 上部:表示(Entryを右寄せ・読み取り専用風)
        entry = ttk.Entry(self.root, textvariable=self.display_var,
                          justify="right", style="Display.TEntry")
        entry.state(["readonly"])  # 入力はボタン/キーからのみ
        entry.grid(row=0, column=0, columnspan=4, sticky="nsew", padx=8, pady=(12, 8))

        # ボタン定義(表示ラベル, 内部トークン, ハンドラ)
        buttons = [
            [("C", "C", self.clear), ("⌫", "BACK", self.backspace), ("%", "%", self.percent), ("÷", "/", self.input_op)],
            [("7", "7", self.input_char), ("8", "8", self.input_char), ("9", "9", self.input_char), ("×", "*", self.input_op)],
            [("4", "4", self.input_char), ("5", "5", self.input_char), ("6", "6", self.input_char), ("−", "-", self.input_op)],
            [("1", "1", self.input_char), ("2", "2", self.input_char), ("3", "3", self.input_char), ("+", "+", self.input_op)],
            [("+/−", "NEG", self.negate), ("0", "0", self.input_char), (".", ".", self.input_dot), ("=", "=", self.equals)],
        ]

        # 配置
        for r, row in enumerate(buttons, start=1):
            for c, (label, token, handler) in enumerate(row):
                btn = ttk.Button(self.root, text=label, style="Calc.TButton",
                                 command=lambda t=token, h=handler: h(t))
                btn.grid(row=r, column=c, sticky="nsew", padx=4, pady=4)

        # 伸縮設定:表示・ボタンがリサイズに追従
        for i in range(4):
            self.root.columnconfigure(i, weight=1, uniform="col")
        for i in range(6):  # 0行は表示、1〜5行はボタン
            self.root.rowconfigure(i, weight=1)

    def _bind_keys(self):
        # 数字・演算子・括弧・ドット
        for ch in "0123456789()+-*/.%":
            self.root.bind(ch, self._on_key)
        # Enter=計算、BackSpace=⌫、Escape=クリア
        self.root.bind("<Return>", lambda e: self.equals("="))
        self.root.bind("<KP_Enter>", lambda e: self.equals("="))
        self.root.bind("<BackSpace>", lambda e: self.backspace("BACK"))
        self.root.bind("<Escape>", lambda e: self.clear("C"))

    # ----------------- 入力系ハンドラ -----------------
    def input_char(self, ch: str):
        """数字入力など:内部式と表示を更新"""
        self._append(ch)

    def input_op(self, opch: str):
        """演算子入力:直前が演算子なら置換、数字なら追加"""
        if not self.internal_expr:
            # 先頭に演算子は原則不可(ただし単項-はNEGで対応)
            if opch in "+*/%":
                return
        if self.internal_expr and self.internal_expr[-1] in "+-*/%.":
            self.internal_expr = self.internal_expr[:-1] + opch
        else:
            self.internal_expr += opch
        self._sync_display()

    def input_dot(self, _):
        """小数点:直前が演算子or空なら '0.' を挿入、数値中の2重ドットは抑止"""
        if not self.internal_expr or self.internal_expr[-1] in "+-*/%(":
            self._append("0.")
            return
        # 直近の数値に既に '.' が含まれていないかチェック
        last = self._last_number()
        if "." in last:
            return
        self._append(".")

    def negate(self, _):
        """±:直近の数値の符号を反転"""
        if not self.internal_expr:
            return
        idx, num = self._last_number(with_index=True)
        if not num:
            return
        if num.startswith("-"):
            new = num[1:]  # マイナスを外す
        else:
            new = "-" + num
        self.internal_expr = self.internal_expr[:idx] + new + self.internal_expr[idx + len(num):]
        self._sync_display()

    def percent(self, _):
        """%:直近の数値を 1/100 に(例:12 → 0.12)"""
        if not self.internal_expr:
            return
        idx, num = self._last_number(with_index=True)
        if not num:
            return
        try:
            val = float(num) / 100.0
        except Exception:
            return
        new = str(val)
        self.internal_expr = self.internal_expr[:idx] + new + self.internal_expr[idx + len(num):]
        self._sync_display()

    def backspace(self, _):
        """⌫:最後の1文字を削除"""
        if not self.internal_expr:
            return
        self.internal_expr = self.internal_expr[:-1]
        self._sync_display(default_zero=True)

    def clear(self, _):
        """C:全クリア"""
        self.internal_expr = ""
        self.display_var.set("0")

    def equals(self, _):
        """=:安全寄り評価で計算して表示に反映"""
        if not self.internal_expr:
            return
        try:
            # 括弧のバランスや末尾演算子チェックは ast が構文エラーで検出
            result = safe_eval(self.internal_expr)
            # 表示は整数っぽければ小数点を省く
            if float(result).is_integer():
                self.internal_expr = str(int(result))
            else:
                self.internal_expr = str(result)
            self._sync_display()
        except ZeroDivisionError:
            self.display_var.set("エラー(0除算)")
            self.internal_expr = ""
        except Exception:
            self.display_var.set("エラー(式が不正)")
            self.internal_expr = ""

    def _on_key(self, event):
        ch = event.char
        if ch in "0123456789()":
            self._append(ch)
        elif ch in "+-*/%":
            self.input_op(ch)
        elif ch == ".":
            self.input_dot(".")
        # それ以外は無視

    # ----------------- 内部ユーティリティ -----------------
    def _append(self, token: str):
        self.internal_expr += token
        self._sync_display()

    def _sync_display(self, default_zero=False):
        """内部式 → 表示。見やすさのため ×÷− の置換はここでも可能。"""
        disp = self.internal_expr
        # 表示だけ ×, ÷ に置き換えたい場合は以下を有効化:
        disp = disp.replace("*", "×").replace("/", "÷")
        if default_zero and not disp:
            disp = "0"
        self.display_var.set(disp if disp else "0")

    def _last_number(self, with_index=False):
        """内部式の末尾から連続する数値部分を抽出。index付き返却も可。"""
        s = self.internal_expr
        if not s:
            return ("", "") if with_index else ""
        i = len(s) - 1
        # 数値(0-9, ., 先頭の-)を逆走査
        while i >= 0 and (s[i].isdigit() or s[i] == "."):
            i -= 1
        # 単項マイナスに対応(演算子や括弧の直後の '-' は数値に含める)
        if i >= 0 and s[i] == "-" and (i == 0 or s[i-1] in "+-*/%("):
            i -= 1
        num = s[i+1:]
        return (i+1, num) if with_index else num

if __name__ == "__main__":
    root = tk.Tk()
    app = CalculatorApp(root)
    root.mainloop()

試し方:保存後、コマンドラインで python calculator_tk.py を実行。ウィンドウが開けば成功です。数字や演算子ボタンのほか、キーボードの数字・+ - * / % .、Enter(=)、Backspace(⌫)、Esc(C)が使えます。


6. コードの読み方(要点を一気に掴む)

  • 安全寄り評価:safe_eval()ast.parse(..., mode="eval") の結果について、二項演算(+ - * / // % **)と単項演算(+/-)と数値リテラル(int/float)だけを再帰評価。関数呼び出しや名前参照は例外にします。
  • UIとロジックの分離:表示テキストは StringVar で束縛、内部の式文字列は別に保持。見た目(×、÷など)と内部ロジック(*/)の役割を分けて実装を簡潔に。
  • grid配置:行列でボタンを並べ、columnconfigure/rowconfigureweight を設定してリサイズ対応。
  • イベント:ボタンの command と、root.bind() でキー入力を拾う二系統。
  • ±・%:末尾の数値だけを取り出して加工。全体の式構造は崩さないのがコツ。

7. よくあるつまずき(トラブル対処)

  • 「式が不正」エラーが出る:末尾が演算子、括弧の開閉不一致、小数点の重複など。構文エラーは ast.parse が検出します。
  • 0除算:例外をキャッチして「エラー(0除算)」を表示。アプリが落ちないよう防御。
  • 小数点が二度入る:直近の数値を検査し、既に . があれば無視。
  • 演算子が連続する:直前が演算子なら置換にするなどの「簡易矯正」を実装。

8. EXE化して配布(任意)

Python未インストールのPCでも動かしたい場合、PyInstallerで単一実行ファイル化できます(Windowsでビルド)。

pip install pyinstaller
pyinstaller --noconsole --onefile calculator_tk.py

完了すると dist/calculator_tk.exe が生成されます。必要に応じて --icon=icon.ico を追加し、アイコンを設定しましょう。PyInstallerはクロスコンパイラではないため、Windows版EXEはWindows上で作る必要がある点に注意してください。


9. ここまでで学べるPython基礎

  • 関数・クラス:役割ごとに関数化、UIはクラスで構造化
  • 標準ライブラリ:tkinterとastを組み合わせて「安全寄りの電卓」
  • イベント駆動:GUIは「押されたら動く」コールバック設計
  • 例外処理:ユーザー入力は必ず失敗し得る=落ちないコードが重要

10. 発展アイデア

  • 履歴(Tape)パネルを追加して、計算過程をスクロール表示
  • 関数キー:sqrtsinなど数学関数(許可関数だけ追加し安全を担保)
  • テーマ切替(Light/Dark)、フォント拡大、アクセシビリティ配慮
  • 単位変換(長さ・重さ・通貨)モードの追加

参考・出典

  • tkinter 公式ドキュメント:Python標準のTkインターフェースの解説。レイアウトやウィジェットの基本がまとまっています。 :contentReference[oaicite:0]{index=0}
  • TkDocs:gridの実践的チュートリアル(GUIの行×列レイアウトの基礎)。 :contentReference[oaicite:1]{index=1}
  • ast モジュール 公式ドキュメント:抽象構文木を扱う標準モジュール。安全寄り評価のための基礎知識。 :contentReference[oaicite:2]{index=2}
  • PyInstaller 公式:単体実行ファイル化の手順と注意点。 :contentReference[oaicite:3]{index=3}

以上で、標準ライブラリだけで作る電卓アプリの完成です。tkinterは“古典的”な手触りながら、学習と小規模ユーティリティには今も強力な選択肢です。まずはこの電卓で、PythonとGUIの「手応え」を掴んでください。慣れてきたら機能拡張やデザイン改善にも挑戦してみましょう。

コメント

タイトルとURLをコピーしました