/ PROGRAMMING, DEBUGGING, PYTHON

소프트웨어공학 퀴즈 및 중간고사 대비

The Debugging Book에서 핵심 내용만 요약 정리해서 미사카 미코토 공부법으로 만점 쟁취해 봅시다.

Intro_Debugging

  1. Understand the code
  2. Fix the problem, not the symptom
  3. Proceed systematically

which is what we will apply for the rest of this chapter.

Since the code was originally written by a human, any defect can be related to some original mistake the programmer made. This gives us a number of terms that all are more precise than the general “error” or the colloquial “bug”:

  • A mistake is a human act or decision resulting in an error.
  • A defect is an error in the program code. Also called bug.
  • A fault is an error in the program state. Also called infection.
  • A failure is an externally visible error in the program behavior. Also called malfunction.

The cause-effect chain of events is thus

  • Mistake → Defect → Fault → … → Fault → Failure

Note that not every defect also causes a failure, which is despite all testing, there can still be defects in the code looming around until the right conditions are met to trigger them. On the other hand, though, every failure can be traced back to the defect that causes it. Our job is to break the cause-effect chain.

Defect에 의해 발생한 Fault들로 형성되는 연쇄의 사슬에서 가장 처음 이른 state에서 발생한 fault를 찾아내는 것이 핵심입니다.

Correct State -> Faulty state로 넘어가는 transition 지점을 찾아야 합니다.

Debugging strategy 실제 프로그램이 갖는 state의 수는 매우 많기 때문에 사람이 일일이 추적하기 어렵습니다. 즉, 의심되는 부분을 집중 분석하고 그 다음 덜 중요한 부분을 분석하는 식으로 debugging하는 전략이 필요합니다.

프로그램 중간에 assertion을 삽입하는 것도 도움이 됩니다.

assert condition

Python에서는 위의 statement를 사용할 수 있습니다.

The Scientific Method

  1. Formulate a question, as in “Why does this apple fall down?”.
  2. Invent a hypothesis based on knowledge obtained while formulating the question, that may explain the observed behavior.
  3. Determining the logical consequences of the hypothesis, formulate a prediction that can support or refute the hypothesis. Ideally, the prediction would distinguish the hypothesis from likely alternatives.
  4. Test the prediction (and thus the hypothesis) in an experiment. If the prediction holds, confidence in the hypothesis increases; otherwise, it decreases.
  5. Repeat Steps 2–4 until there are no discrepancies between hypothesis and predictions and/or observations.

Checking Diagnoses

In debugging, you should start to fix your code if and only if you have a diagnosis that shows two things:

  1. Causality. Your diagnosis should explain why and how the failure came to be. Hence, it induces a fix that, when applied, should make the failure disappear.
  2. Incorrectness. Your diagnosis should explain why and how the code is incorrect (which in turn suggests how to correct the code). Hence, the fix it induces not only applies to the given failure, but also to all related failures.

Tracer

Tracer class: 프로그램 실행 중 발생하는 이벤트를 로깅함

EventTracer class: 특정 조건에서만 로깅하도록 제한

위 클래스들은 subclassing을 허용한다.

>>> with EventTracer(condition='line == 223 or len(out) >= 6'):
>>>     remove_html_markup('<b>foo</b>bar')

특정 condition(breakpoint)뿐만 아니라, 주어진 events 내 값들이 변화할 때(watchpoint)만 로깅하도록 제한할 수도 있습니다.

>>> with EventTracer(events=["c == '/'"]): # c == '/' 평가 결과 True, False 값이 변화할 때만 로깅
>>>     remove_html_markup('<b>foo</b>bar')

sys.settrace()

sys.settrace() 함수에 매 line마다 호출될 tracing function을 전달하여 프로그램 실행 중 발생하는 이벤트를 로깅할 수 있습니다. None 을 전달하면 tracing을 끕니다.

tracing function은 아래와 같은 형태

# tracing function
def traceit(frame: FrameType, event: str, arg: Any) -> Optional[Callable]:
    ...

Here, event is a string telling what has happened in the program – for instance,

  • 'line' – a new line is executed (정확히는, 실행 전에 line 이벤트가 먼저 발생함)
  • 'call' – a function just has been called
  • 'return' – a function returns
  • 'exception' – an exception has been raised

The frame argument holds the current execution frame – that is, the function and its local variables:

  • frame.f_lineno – the current line
  • frame.f_locals – the current variables (as a Python dictionary)
  • frame.f_code – the current code (as a Code object), with attributes such as
    • frame.f_code.co_name – the name of the current function

tracing function의 리턴값은 다음 이벤트 때 실행될 함수에 해당하며, 일반적으로는 자기 자신을 리턴합니다.

여기서 arg는 이벤트에 따라 다른 의미를 가집니다.

event arg
call 호출된 함수
return 반환값
exception 예외 객체
line None

여기서 호출된 함수라는 건, 함수 이름이 아니라 함수 객체를 의미하는 것 같습니다. 이름에 접근하려면 frame.f_code.co_name을 사용해야 합니다.

Tracer 클래스 만들기

The typical usage of Tracer, however, is as follows:

with Tracer():
    # Code to be traced
    ...

# Code no longer traced
...

When the with statement is encountered, the __enter__() method is called, which starts tracing. When the with block ends, the __exit__() method is called, and tracing is turned off. We take special care that the internal __exit__() method is not part of the trace, and that any other tracing function that was active before is being restored.

미리 정의된 StackInspector라는 클래스를 상속받아서 Tracer 클래스를 정의하고 있는데, our_frame() 이랑 is_internal_error()라는 메소드가 에러 상황에서 더 나은 정보를 준다고 하네요. -> 직접 구현하려면 StackInspector는 이용 불가하니 무시

class Tracer(StackInspector):
    """A class for tracing a piece of code. Use as `with Tracer(): block()`"""

    def __init__(self, *, file: TextIO = sys.stdout) -> None:
        """Trace a block of code, sending logs to `file` (default: stdout)"""
        self.original_trace_function: Optional[Callable] = None # 이따 __enter__에서 기존 tracing function이 저장된다.
        self.file = file

    def traceit(self, frame: FrameType, event: str, arg: Any) -> None:
        """Tracing function. To be overridden in subclasses."""
        self.log(event, frame.f_lineno, frame.f_code.co_name, frame.f_locals)

    def _traceit(self, frame: FrameType, event: str, arg: Any) -> Optional[Callable]: # private
        """Internal tracing function."""
        if self.our_frame(frame):
            # Do not trace our own methods
            pass
        else:
            self.traceit(frame, event, arg)
        return self._traceit
    
    # 굳이 이렇게 구현하지 말고 traceit 함수 내에서 frame.f_code.co_name을 이용해 현재 함수가 Tracer 클래스의 메소드인지 확인하면 될 듯

    def log(self, *objects: Any, 
            sep: str = ' ', end: str = '\n', 
            flush: bool = True) -> None:
        """
        Like `print()`, but always sending to `file` given at initialization,
        and flushing by default.
        """
        print(*objects, sep=sep, end=end, file=self.file, flush=flush)

    def __enter__(self) -> Any: # with 구문 진입 시 호출
        """Called at begin of `with` block. Turn tracing on."""
        self.original_trace_function = sys.gettrace() # 현재 사용 중인 tracing function을 저장
        sys.settrace(self._traceit)

        # This extra line also enables tracing for the current block ?? (그냥 convention에 가깝다고 함. 중요하지는 않은 듯)
        # inspect.currentframe().f_back.f_trace = self._traceit
        return self

    def __exit__(self, exc_tp: Type, exc_value: BaseException, 
                 exc_traceback: TracebackType) -> Optional[bool]:
        """
        Called at end of `with` block. Turn tracing off.
        Return `None` if ok, not `None` if internal error.
        """
        sys.settrace(self.original_trace_function)

        # Note: we must return a non-True value here,
        # such that we re-raise all exceptions
        if self.is_internal_error(exc_tp, exc_value, exc_traceback):
            return False  # internal error
        else:
            return None  # all ok

간단히 해석하자면, __enter____exit__with 블록 내에서만 사용할 tracing function을 지정하고, with 블록을 나갈 때 원래 tracing function을 복원하는 기능을 구현했네요.

그리고 file argument로 log를 출력할 스트림을 지정 가능하구요.

_traceit은 자기 함수를 제외하고 다른 함수들만 적용된다는 조건 하에 traceit을 호출하는 wrapper 함수처럼 보이는데… 이것이 private이고 traceitpublic이라는 것은 어떤 의미를 가질까요? 그냥 보기에는 _traceit이 먼저 호출되고 그 안에서 traceit을 호출해야 하는 것처럼 보이는데 말이죠… 외부에선 직접 traceit을 호출한다는 소리일까요?

__enter__ 함수 보면 답이 나옵니다. 여기서 아예 sys.settrace()_traceit을 전달하고 있네요. 그래서 with 블록 내에서 tracing function으로 _traceit이 호출되고, _traceittraceit을 호출하는 방식으로 동작하는 것이죠.

소스 코드 접근

inspect 모듈을 사용하면 소스 코드에 접근할 수 있습니다.

import inspect

...

module = inspect.getmodule(frame.f_code)
source = inspect.getsource(module)

이제 줄번호만 알면 되겠네요. 줄번호는 frame.f_lineno에 있으니, source를 줄 단위로 나누고 frame.f_lineno와 함께 그 값에 해당하는 줄을 출력하면 되겠죠.

Python hack: 메소드를 추가적으로 정의하기

class C(C):
    def new_method(self):
        pass

이런 식으로 자기 자신을 상속하면 기존 클래스를 상속하는 새로운 클래스가 생성되며, shadowing이 일어나면서 메소드 정의하는 코드를 연속적으로 작성하지 않고도 새 메소드를 추가할 수 있어요.

여기서는 한 눈에 보기 쉽게 그냥 클래스가 업데이트될 때마다 Tracer 클래스 전체 정의를 보여주도록 하겠습니다.

class Tracer(StackInspector):
    """A class for tracing a piece of code. Use as `with Tracer(): block()`"""

    def __init__(self, *, file: TextIO = sys.stdout) -> None:
        """Trace a block of code, sending logs to `file` (default: stdout)"""
        self.original_trace_function: Optional[Callable] = None # 이따 __enter__에서 기존 tracing function이 저장된다.
        self.file = file

    def traceit(self, frame: FrameType, event: str, arg: Any) -> None:
        """Tracing function; called at every line. To be overloaded in subclasses."""

        if event == 'line':
            module = inspect.getmodule(frame.f_code)
            if module is None:
                source = inspect.getsource(frame.f_code)
            else:
                source = inspect.getsource(module)
            current_line = source.split('\n')[frame.f_lineno - 1]
            self.log(frame.f_lineno, current_line)

    def _traceit(self, frame: FrameType, event: str, arg: Any) -> Optional[Callable]: # private
        """Internal tracing function."""
        if self.our_frame(frame):
            # Do not trace our own methods
            pass
        else:
            self.traceit(frame, event, arg)
        return self._traceit
    
    # 굳이 이렇게 구현하지 말고 traceit 함수 내에서 frame.f_code.co_name을 이용해 현재 함수가 Tracer 클래스의 메소드인지 확인하면 될 듯

    def log(self, *objects: Any, 
            sep: str = ' ', end: str = '\n', 
            flush: bool = True) -> None:
        """
        Like `print()`, but always sending to `file` given at initialization,
        and flushing by default.
        """
        print(*objects, sep=sep, end=end, file=self.file, flush=flush)

    def __enter__(self) -> Any: # with 구문 진입 시 호출
        """Called at begin of `with` block. Turn tracing on."""
        self.original_trace_function = sys.gettrace() # 현재 사용 중인 tracing function을 저장
        sys.settrace(self._traceit)

        # This extra line also enables tracing for the current block ?? (그냥 convention에 가깝다고 함. 중요하지는 않은 듯)
        # inspect.currentframe().f_back.f_trace = self._traceit
        return self

    def __exit__(self, exc_tp: Type, exc_value: BaseException, 
                 exc_traceback: TracebackType) -> Optional[bool]:
        """
        Called at end of `with` block. Turn tracing off.
        Return `None` if ok, not `None` if internal error.
        """
        sys.settrace(self.original_trace_function)

        # Note: we must return a non-True value here,
        # such that we re-raise all exceptions
        if self.is_internal_error(exc_tp, exc_value, exc_traceback):
            return False  # internal error
        else:
            return None  # all ok

inspect.getmodule(frame.f_code): frame의 code object가 속한 module을 반환합니다. 만약 module이 없다면 code object 자체를 반환합니다.

inspect.getsource(frame.f_code): frame의 code object의 소스 코드를 반환합니다.

inspect.getsource(module): module의 소스 코드를 반환합니다.

이런 식으로 traceit을 재정의 가능합니다.

이제 다음과 같이 사용할 수 있습니다.

with Tracer():
    remove_html_markup('<b>foo</b>bar')

변수 값 변화 추적

class Tracer(StackInspector):
    """A class for tracing a piece of code. Use as `with Tracer(): block()`"""
    def __init__(self, file: TextIO = sys.stdout) -> None:
        """
        Create a new tracer.
        If `file` is given, output to `file` instead of stdout.
        """
        """Trace a block of code, sending logs to `file` (default: stdout)"""
        self.original_trace_function: Optional[Callable] = None # 이따 __enter__에서 기존 tracing function이 저장된다.
        self.file = file
        self.last_vars: Dict[str, Any] = {}
        super().__init__(file=file)

    def changed_vars(self, new_vars: Dict[str, Any]) -> Dict[str, Any]:
        """Track changed variables, based on `new_vars` observed."""
        changed = {}
        for var_name, var_value in new_vars.items():
            if (var_name not in self.last_vars or
                    self.last_vars[var_name] != var_value):
                changed[var_name] = var_value
        self.last_vars = new_vars.copy()
        return changed

    def traceit(self, frame: FrameType, event: str, arg: Any) -> None:
        """Tracing function; called at every line. To be overloaded in subclasses."""

        if event == 'line':
            module = inspect.getmodule(frame.f_code)
            if module is None:
                source = inspect.getsource(frame.f_code)
            else:
                source = inspect.getsource(module)
            current_line = source.split('\n')[frame.f_lineno - 1]
            self.log(frame.f_lineno, current_line)

    def _traceit(self, frame: FrameType, event: str, arg: Any) -> Optional[Callable]: # private
        """Internal tracing function."""
        if self.our_frame(frame):
            # Do not trace our own methods
            pass
        else:
            self.traceit(frame, event, arg)
        return self._traceit
    
    # 굳이 이렇게 구현하지 말고 traceit 함수 내에서 frame.f_code.co_name을 이용해 현재 함수가 Tracer 클래스의 메소드인지 확인하면 될 듯

    def log(self, *objects: Any, 
            sep: str = ' ', end: str = '\n', 
            flush: bool = True) -> None:
        """
        Like `print()`, but always sending to `file` given at initialization,
        and flushing by default.
        """
        print(*objects, sep=sep, end=end, file=self.file, flush=flush)

    def __enter__(self) -> Any: # with 구문 진입 시 호출
        """Called at begin of `with` block. Turn tracing on."""
        self.original_trace_function = sys.gettrace() # 현재 사용 중인 tracing function을 저장
        sys.settrace(self._traceit)

        # This extra line also enables tracing for the current block ?? (그냥 convention에 가깝다고 함. 중요하지는 않은 듯)
        # inspect.currentframe().f_back.f_trace = self._traceit
        return self

    def __exit__(self, exc_tp: Type, exc_value: BaseException, 
                 exc_traceback: TracebackType) -> Optional[bool]:
        """
        Called at end of `with` block. Turn tracing off.
        Return `None` if ok, not `None` if internal error.
        """
        sys.settrace(self.original_trace_function)

        # Note: we must return a non-True value here,
        # such that we re-raise all exceptions
        if self.is_internal_error(exc_tp, exc_value, exc_traceback):
            return False  # internal error
        else:
            return None  # all ok

위와 같이 변수들의 값을 저장해두고 변화가 있는 변수들만 감지하는 메소드를 구현 가능합니다. 이를 이용해서 tracing function에 값이 변한 변수만 로깅하도록 만들 수도 있겠죠.

class Tracer(StackInspector):
    """A class for tracing a piece of code. Use as `with Tracer(): block()`"""
    def __init__(self, file: TextIO = sys.stdout) -> None:
        """
        Create a new tracer.
        If `file` is given, output to `file` instead of stdout.
        """
        """Trace a block of code, sending logs to `file` (default: stdout)"""
        self.original_trace_function: Optional[Callable] = None # 이따 __enter__에서 기존 tracing function이 저장된다.
        self.file = file
        self.last_vars: Dict[str, Any] = {}
        super().__init__(file=file)

    def changed_vars(self, new_vars: Dict[str, Any]) -> Dict[str, Any]:
        """Track changed variables, based on `new_vars` observed."""
        changed = {}
        for var_name, var_value in new_vars.items():
            if (var_name not in self.last_vars or
                    self.last_vars[var_name] != var_value):
                changed[var_name] = var_value
        self.last_vars = new_vars.copy()
        return changed

    def print_debugger_status(self, frame: FrameType, event: str, arg: Any) -> None:
        """Show current source line and changed vars"""
        changes = self.changed_vars(frame.f_locals)
        changes_s = ", ".join([var + " = " + repr(changes[var])
                               for var in changes])

        if event == 'call':
            self.log("Calling " + frame.f_code.co_name + '(' + changes_s + ')')
        elif changes:
            self.log(' ' * 40, '#', changes_s)

        if event == 'line':
            try:
                module = inspect.getmodule(frame.f_code)
                if module is None:
                    source = inspect.getsource(frame.f_code)
                else:
                    source = inspect.getsource(module)
                current_line = source.split('\n')[frame.f_lineno - 1]

            except OSError as err:
                self.log(f"{err.__class__.__name__}: {err}")
                current_line = ""

            self.log(repr(frame.f_lineno) + ' ' + current_line)

        if event == 'return':
            self.log(frame.f_code.co_name + '()' + " returns " + repr(arg))
            self.last_vars = {}  # Delete 'last' variables

    def traceit(self, frame: FrameType, event: str, arg: Any) -> None:
        """Tracing function; called at every line. To be overloaded in subclasses."""
        self.print_debugger_status(frame, event, arg)

    def _traceit(self, frame: FrameType, event: str, arg: Any) -> Optional[Callable]: # private
        """Internal tracing function."""
        if self.our_frame(frame):
            # Do not trace our own methods
            pass
        else:
            self.traceit(frame, event, arg)
        return self._traceit
    
    # 굳이 이렇게 구현하지 말고 traceit 함수 내에서 frame.f_code.co_name을 이용해 현재 함수가 Tracer 클래스의 메소드인지 확인하면 될 듯

    def log(self, *objects: Any, 
            sep: str = ' ', end: str = '\n', 
            flush: bool = True) -> None:
        """
        Like `print()`, but always sending to `file` given at initialization,
        and flushing by default.
        """
        print(*objects, sep=sep, end=end, file=self.file, flush=flush)

    def __enter__(self) -> Any: # with 구문 진입 시 호출
        """Called at begin of `with` block. Turn tracing on."""
        self.original_trace_function = sys.gettrace() # 현재 사용 중인 tracing function을 저장
        sys.settrace(self._traceit)

        # This extra line also enables tracing for the current block ?? (그냥 convention에 가깝다고 함. 중요하지는 않은 듯)
        # inspect.currentframe().f_back.f_trace = self._traceit
        return self

    def __exit__(self, exc_tp: Type, exc_value: BaseException, 
                 exc_traceback: TracebackType) -> Optional[bool]:
        """
        Called at end of `with` block. Turn tracing off.
        Return `None` if ok, not `None` if internal error.
        """
        sys.settrace(self.original_trace_function)

        # Note: we must return a non-True value here,
        # such that we re-raise all exceptions
        if self.is_internal_error(exc_tp, exc_value, exc_traceback):
            return False  # internal error
        else:
            return None  # all ok

여기서는 traceit() 함수에서 직접 디버깅 메시지를 출력하던 것이 새 메소드 print_debugger_status()로 분리되었네요.

자 주의할 점은 line 이벤트 이후에 statement의 evaluation이 일어나기 때문에 변수 변화의 출력은 line 이벤트 이후에 이루어집니다.

Conditional Tracing

class ConditionalTracer(Tracer):
    def __init__(self, *, condition: Optional[str] = None, file: TextIO = sys.stdout) -> None:
        """Constructor. Trace all events for which `condition` (a Python expr) holds."""

        if condition is None:
            condition = 'False'

        self.condition: str = condition
        self.last_report: Optional[bool] = None
        super().__init__(file=file)

    def eval_in_context(self, expr: str, frame: FrameType) -> Optional[bool]:
        frame.f_locals['function'] = frame.f_code.co_name
        frame.f_locals['line'] = frame.f_lineno
        try:
            cond = eval(expr, None, frame.f_locals)
        except NameError:  # (yet) undefined variable
            cond = None
        return cond

    def do_report(self, frame: FrameType, event: str, arg: Any) -> Optional[bool]: # report 할지 말지 결정
        return self.eval_in_context(self.condition, frame)

    def traceit(self, frame: FrameType, event: str, arg: Any) -> None:
        report = self.do_report(frame, event, arg)
        if report != self.last_report:
            # report == False 이고 self.last_report == True인 경우에는 조건 만족 연속구간이 끊겼으므로 다음 조건 만족시에 ... 출력함
            if report:
                self.log("...") # 조건 만족이 시작되는 지점에서 리포트를 시작함을 표시
            self.last_report = report # 연속적으로 조건이 참이면 ...을 출력하지 않음

        if report:
            self.print_debugger_status(frame, event, arg)

이런 식으로 조건을 만족하는 경우에만 tracing을 하도록 만들 수 있습니다. 여기서 핵심은 eval_in_context 함수입니다. 이것으로 조건 만족 여부에 따라, tracing을 할지 말지 결정할 수 있게 되는 것이죠.

eval_in_context 함수에서 임의로 functionline 변수를 추가한 것을 주목하세요. 이렇게 하면 함수 이름과 줄번호를 조건으로 줄 수가 있는 것이죠. 주의할 점은, 이 시점에서 원래의 지역 변수에 function이나 line이 존재했다면 값이 덮어씌워지기 때문에 원래의 지역 변수에 functionline이 있었다면 Tracer에서 접근할 수 없게 됩니다.

Event Tracing

class EventTracer(ConditionalTracer):
    """Log when a given event expression changes its value"""

    def __init__(self, *, condition: Optional[str] = None,
                 events: List[str] = [], file: TextIO = sys.stdout) -> None:
        """Constructor. `events` is a list of expressions to watch."""
        self.events = events
        self.last_event_values: Dict[str, Any] = {}
        super().__init__(file=file, condition=condition)

    def events_changed(self, events: List[str], frame: FrameType) -> bool:
        """Return True if any of the observed `events` has changed"""
        change = False
        for event in events:
            value = self.eval_in_context(event, frame)

            if (event not in self.last_event_values or
                    value != self.last_event_values[event]):
                self.last_event_values[event] = value
                change = True

        return change

    def do_report(self, frame: FrameType, event: str, arg: Any) -> bool:
        """Return True if a line should be shown"""
        return (self.eval_in_context(self.condition, frame) or
                self.events_changed(self.events, frame))

events로 전달된 expression(변수의 값, 함수 자체)이 변화할 때만 로깅하도록 만들 수 있습니다.

아래와 같이 사용 가능합니다.

with EventTracer(events=["c == '/'"]):
    remove_html_markup('<b>foo</b>bar')

Efficient Tracing

동적으로 condition을 만족하는지 일일이 확인하는 Tracing은 매우 overhead가 큰 작업이라 tracing을 하지 않을 때보다 훨씬 실행 시간이 길어집니다. Frida 때를 생각해 보면 답 나오죠?

아예 tracing 코드를 특정 함수에만 정적으로 삽입하는 방식을 쓰면 부하를 훨씬 줄일 수가 있어요. 이때는 tracing 대상 코드가 아닌 부분은 원래의 실행 속도가 유지 가능하거든요. 방법을 알아봅시다.

함수와 breakpoint의 위치를 인자로 받아 거기에 tracing statements를 인젝션하는 insert_tracer라는 함수를 정의해 봅시다.

TRACER_CODE = \
    "TRACER.print_debugger_status(inspect.currentframe(), 'line', None); "

TRACER = Tracer()

def insert_tracer(function: Callable, breakpoints: List[int] = []) -> Callable:
    """Return a variant of `function` with tracing code `TRACER_CODE` inserted
       at each line given by `breakpoints`."""

    source_lines, starting_line_number = inspect.getsourcelines(function)

    breakpoints.sort(reverse=True)
    for given_line in breakpoints:
        # Set new source line
        relative_line = given_line - starting_line_number + 1
        inject_line = source_lines[relative_line - 1]
        indent = len(inject_line) - len(inject_line.lstrip())
        source_lines[relative_line - 1] = ' ' * indent + TRACER_CODE + inject_line.lstrip()

    # Rename function
    new_function_name = function.__name__ + "_traced"
    source_lines[0] = source_lines[0].replace(function.__name__, new_function_name)
    new_def = "".join(source_lines)

    # For debugging
    print_content(new_def, '.py', start_line_number=starting_line_number)

    # We keep original source and filename to ease debugging
    prefix = '\n' * starting_line_number    # Get line number right
    new_function_code = compile(prefix + new_def, function.__code__.co_filename, 'exec')
    exec(new_function_code)
    new_function = eval(new_function_name)
    return new_function

이러면 sys.settrace() 함수의 힘을 빌리지 않아도 특정 라인(breakpoints)에서 강제로 debugging information을 출력하도록 함으로써, 결과적으로 그 부분만 tracing을 할 수 있게 되는 것이죠.

이제 다음과 같이 사용할 수 있습니다.

_, remove_html_markup_starting_line_number = inspect.getsourcelines(remove_html_markup)
breakpoints = [(remove_html_markup_starting_line_number - 1) + 7, 
               (remove_html_markup_starting_line_number - 1) + 18]

remove_html_markup_traced = insert_tracer(remove_html_markup, breakpoints)

참고로, 인터프리터 언어가 아닌 컴파일 언어에서는 위와 비슷한 static한 방식으로 디버깅이 이루어집니다. Binary code 내 특정 위치에 있는 instruction을 break instruction으로 바꾸고 debugger에게 control을 넘기는 방식이죠. 그리고 debugger는 실행을 재개하기 전 그 instruction을 원래대로 복구하는 방식으로 동작합니다. 물론 read-only 영역이라 코드를 대체할 수 없는 경우는 한 줄씩 실행하고 상태를 살펴보는 dynamic한 방식으로 디버깅이 이루어집니다. 한편, 실제 레지스터의 값이나 메모리의 특정 영역의 값의 변화에 따라 interrupt가 발생하는 hardware breakpint 또는 hardware watchpoint도 있습니다.

Lessons Learned

  • Interpreted languages can provide debugging hooks that allow controlling program execution and access program state.
  • Tracing can be limited to specific conditions and events:
    • A breakpoint is a condition referring to a particular location in the code.
    • A watchpoint is an event referring to a particular state change.
  • Compiled languages allow instrumenting code at compile time, injecting code that allows handing over control to a tracing or debugging tool.

Low-Level Debugging Interfaces

Linux는 ptrace()라는 시스템 call을 제공하는데 이를 이용하면 프로세스의 실행을 추적하거나 제어할 수 있습니다. 이를 이용해서 gdb와 같은 디버깅 툴을 만들 수 있죠. 그리고 컴파일러는 -g 옵션을 통해 디버깅 정보를 포함한 binary를 생성할 수 있습니다. 이때 디버깅 정보는 instruction을 original statement로 맵핑하고 메모리의 내용물을 변수의 값에 매칭시킬 수 있도록 도와줍니다.

High-Level Debugging Interfaces

Python에서처럼 코드 위치나 변수 High-Level에서 프로그램의 동향을 추적하는 데 도움을 주는 라이브러리도 있습니다. 예를들어 JDI라는 Java Debug Interface가 있죠.

Excercises

Exception 추적하기

class Tracer(Tracer):
    def print_debugger_status(self, frame: FrameType, event: str, arg: Any) -> None:
        if event == 'exception':
            exception, value, tb = arg
            self.log(f"{frame.f_code.co_name}() "
                     f"raises {exception.__name__}: {value}")
        else:
            super().print_debugger_status(frame, event, arg)

참고 문헌