Skip to the content.

Pygame 엔진 개발 일지 02: Assert 대탐험 (2023.11.29)

Home

출처: Game Programmging Gems. 1.12 Squeezing More Out of Assert. Steve Rabin.

아직까지 python의 기본 assert만 쓰고 있었다면 인생 절반 손해본 것이나 다름 없다. 기본 assert는 물론 유용한 기능이지만, 여기에 이 기능 저 기능 추가하면 좀 더 유용하게 디버깅을 할 수 있게 된다. 이번 개발 일지에서는 좀 더 유용한 assert문을 구현해보도록 하자.

Assert문 기초

그냥 프로그래머라면 assert가 알파요 오메가여야 한다. 프로그램을 출시하기 전에 여기저기 검증 코드를 덕지 덕지 붙임으로써 방어적으로 프로그래밍을 해줘야 오류가 발생하면 그때 그때 검출하여 고칠 수 있게 된다. 당연히 오남용해서는 안되겠지만, 그래도 웬만하면 최대한 많은 곳에 asert문을 넣는 것이 베스트 프랙티스다.

기본적으로 assert문은 검증 용도이기 때문에 출시 버전에는 포함되지 않는 것이 기본이다. 즉, assert문이 로직에 영향을 줘서는 안되는 것이다.

예시:

import dataclasses

@dataclasses.dataclass
class Vector3f:
    x: float = 0.0
    y: float = 0.0
    z: float = 0.0

def normalize_vector3f(src: Vector3f) -> Vector3f:
    assert isinstance(src, Vector3f)    # 입력 변수 자료형 검증
    assert src is not None  # 입력 변수값 검증

    length: float = sqrt((src.x * src.x) + (src.y * src.y) + (src.z * src.z))

    assert length != 0.0    # 분모가 0인 경우 검출

    dst: Vector3f = Vector3f(x=src.x / length, y=src.y / length, z=src.z / length)
    return dst

Assert문 업그레이드하기 1: 정보 추가해주기

import dataclasses

@dataclasses.dataclass
class Vector3f:
    x: float = 0.0
    y: float = 0.0
    z: float = 0.0

def normalize_vector3f(src: Vector3f) -> Vector3f:
    assert isinstance(src, Vector3f), "input argument type is not Vector3f!!"
    assert src is not None, "input argument is None!!"

    length: float = sqrt((src.x * src.x) + (src.y * src.y) + (src.z * src.z))

    assert length != 0.0, "length of the source vector is 0!!"

    dst: Vector3f = Vector3f(x=src.x / length, y=src.y / length, z=src.z / length)
    return dst

assert문에 쉼표로 뒤에 디버깅용 정보를 추가해줄 수 있다.

Assert문 업그레이드하기 2: 정보 더 추가해주기

만약 코드에서 이 줄에 절대 와서는 안 되는 경우가 있다면, 그냥 디버깅 메시지 앞에 부정문을 붙여주면 된다:

assert not "Code should never get here!!"

Assert문 업그레이드하기 3: 가독성 향상하기

def my_assert(condition: bool, message: str) -> None:
    assert condition, message

...
my_assert(condition=src is not None,
            message="input argument is None!!")
my_assert(condition=False,
            message="Code should never get here!!")
...

Assert문 업그레이드하기 4: Assert문 무시 가능하도록 하기

IGNORE_ASSERT: bool = False

def my_assert(condition: bool, message: str) -> None:
    if IGNORE_ASSERT is False:
        assert condition, message

...
my_assert(condition=src is not None,
            message="input argument is None!!")
my_assert(condition=False,
            message="Code should never get here!!")
...

Assert문 업그레이드하기 5: Debugger 없이도 작동하게 만들기

assert에서 걸릴 때 int 3가 발생하는 것은 debugger가 attach 되어있을 때 가능한 것이다. debugger가 없을 때도 assert에 걸리게 만들고 싶다면 별도의 window 창 같은 걸 띄워서 assert가 걸렸음을 알리고, main thread에서 이 창을 대기하게 만들면 된다.

import multiprocessing

import tkinter as tk
from tkinter import ttk
import traceback

IGNORE_ASSERT: bool = False

class AssertWindow:
    INSTANCE: AssertWindow = None

    @staticmethod
    def start(message: str, stacktrace: str, pipeline: multiprocessing.Pipe) -> None:
        AssertWindow(message=message, stacktrace=stacktrace, pipeline=pipeline)
        print("hi")

    def __init__(self: AssertWindow, message: str, stacktrace: str, pipeline: multiprocessing.Pipe) -> None:
        AssertWindow.INSTANCE = self
        self.__message: str = message
        self.__pipeline: multiprocessing.Pipe = pipeline

        self.__assert_window: tk.Tk = tk.Tk()
        # self.__assert_window.geometry(newGeometry="700x400")

        self.__assert_message_frame: ttk.LabelFrame = \
            ttk.LabelFrame(master=self.__assert_window,
                           text="Assert Message")
        self.__assert_message_frame.pack()
        self.__assert_message: ttk.Label = \
            ttk.Label(master=self.__assert_message_frame,
                      text=self.__message)
        self.__assert_message.pack()

        self.__stacktrace_frame: ttk.LabelFrame = \
            ttk.LabelFrame(master=self.__assert_window,
                           text="Stack Trace")
        self.__stacktrace_frame.pack()

        self.__stacktrace_label: ttk.Label = \
            ttk.Label(master=self.__stacktrace_frame,
                      text=stacktrace)
        self.__stacktrace_label.pack()

        self.__assert_continue: ttk.Button = ttk.Button(master=self.__assert_window,
                                                        text="Ignore Once",
                                                        command=AssertWindow.on_continue)

        self.__assert_stop: ttk.Button = ttk.Button(master=self.__assert_window,
                                                    text="Stop",
                                                    command=AssertWindow.on_stop)
        self.__assert_continue.pack()
        self.__assert_stop.pack()
        self.__assert_window.mainloop()

    @staticmethod
    def on_continue() -> None:
        AssertWindow.INSTANCE.__pipeline.send("is_continue")
        AssertWindow.INSTANCE.__assert_window.quit()

    @staticmethod
    def on_stop() -> None:
        AssertWindow.INSTANCE.__pipeline.send("is_stop")
        AssertWindow.INSTANCE.__assert_window.quit()

def my_assert(condition: bool, message: str) -> None:
    if IGNORE_ASSERT is False:
        if condition is False:
            parent_pipeline, child_pipeline = multiprocessing.Pipe()

            stacktrace: str = ""
            for line in traceback.format_stack():
                stacktrace += line.strip() + "\n"
            assert_window_process: multiprocessing.Process = \
                multiprocessing.Process(target=AssertWindow.start,
                                        name="Assert Window",
                                        args=(log_message, stacktrace, child_pipeline)
                )
            assert_window_process.start()
            message: str = parent_pipeline.recv()
            assert_window_process.join()

            if message == "is_stop":
                breakpoint()

...
my_assert(condition=src is not None,
            message="input argument is None!!")
my_assert(condition=False,
            message="Code should never get here!!")
...

여기에 stack 정보까지 추가해주면 된다.

Assert문 업그레이드하기 6: Assert 메시지 복붙 가능하도록 하기

이거는 pyperclip 모듈을 사용해주면 편하다. 버튼을 하나 더 추가하면 된다:

...
        self.__buttons_frame: ttk.Frame = \
            ttk.Frame(master=self.__assert_window)
        self.__buttons_frame.pack()

        self.__assert_copy_to_clipboard: ttk.Button = ttk.Button(master=self.__buttons_frame,
                                                        text="Copy Message to Clipboard",
                                                        command=AssertWindow.on_copy_to_clipboard)
        self.__assert_continue: ttk.Button = ttk.Button(master=self.__buttons_frame,
                                                        text="Ignore Once",
                                                        command=AssertWindow.on_continue)

        self.__assert_stop: ttk.Button = ttk.Button(master=self.__buttons_frame,
                                                    text="Stop",
                                                    command=AssertWindow.on_stop)
        self.__assert_copy_to_clipboard.pack(side=tk.LEFT)
        self.__assert_continue.pack(side=tk.LEFT)
        self.__assert_stop.pack(side=tk.LEFT)
...
    @staticmethod
    def on_copy_to_clipboard():
        pyperclip.copy(text=AssertWindow.INSTANCE.__message)