콘텐츠로 건너뛰기

[Python] int type 변수의 identity 및 console과 script에서의 실행 결과

이런저런 일로 Python 코드를 작성하고 테스트해 보던 중, identity 관련해서 평소에는 별로 신경 쓰지 않던 재밌는 것을 발견했다. Python에서는 변수를 선언하면 메모리상에 Object의 instance를 생성하고 해당 변수는 instance를 referencing한다. 즉, 모든 변수는 메모리의 instance 주소를 담고 있는 것이다. 간단한 개념이라 관련해서 코드를 직접 작성 후 테스트를 해 보지는 않았는데, int 및 tuple의 identity 관련해서 위의 개념과는 살짝 다르게 동작하는 부분이 있다는 것을 이제서야 알았다.

Int type 변수의 identity

a = 1
b = 4
c = 4
print(a is b)
print(b is c)

위의 코드에서 첫 번째 print문의 출력 결과는 False이다. 변수 a와 b는 서로 다른 정수를 할당받았고, 따라서 서로 다른 instance를 가리키고 있기 때문이다.

그렇다면 두 번째 print문의 결과는 어떨까? 단순히 생각했을 때에는 정수의 값이 같은 것과 무관하게 두 개의 int instance가 메모리에 생성되고 b와 c는 서로 다른 instance를 가리켜야 하므로 조금 전 예시와 마찬가지로 False가 출력될 것 같지만, 유감스럽게도 True가 출력된다.

이 현상은 Python(CPython)의 내부 구현에 따른 것으로, CPython은 [-5, 256] 범위에 있는 모든 정수들의 array를 유지하고 있다가 해당 범위 내의 정수가 필요한 경우 새로운 instance를 생성하는 대신 유지하고 있던 array 내 정수의 reference를 반환한다. 관련해서 CPython c-api 문서중 새로운 int object를 생성하는 함수인  PyLong_FromLong 항목에서 아래와 같이 언급하고 있다.

The current implementation keeps an array of integer objects for all integers between -5 and 256, when you create an int in that range you actually just get back a reference to the existing object.

실제 github의  CPython 코드에서 위의 내용을 확인할 수 있는데, PyLong_FromLong 코드를 살펴보면 아래와 같은 코드가 보인다.

PyObject *
PyLong_FromLong(long ival)
{
    // 생략 ...
    if (IS_SMALL_INT(ival)) {
        return get_small_int((sdigit)ival); 
    }
     // 생략 ... 
}

다른 숫자들은 (내가 이해한 것이 맞다면) _PyLong_New를 통해 새로운 int object를 생성하지만 만약 숫자가 IS_SMALL_INT 매크로에 걸리면 get_small_int 를 호출한다. IS_SMALL_INT 매크로 및 NSMALLNEGINTS, NSMALLPOSINTS는 아래와 같이 정의되어 있다.

#define NSMALLPOSINTS           _PY_NSMALLPOSINTS
#define NSMALLNEGINTS           _PY_NSMALLNEGINTS
// 생략 ...
#define IS_SMALL_INT(ival) (-NSMALLNEGINTS <= (ival) && (ival) < NSMALLPOSINTS)
#define _PY_NSMALLPOSINTS 257
#define _PY_NSMALLNEGINTS 5

즉, PyLong_FromLong은 인자로 받은 정수가 [-5, 256] 범위에 있다면 새로운 int object를 생성하는 대신 get_small_int를 호출하는 것이다. get_small_int 코드를 살펴보면 아래와 같이 small_ints array의 특정 element를 반환하게 되어있다. 아마 small_ints array가 위에서 말한 [-5, 256] 범위 정수가 들어 있는 array이지 싶다.

PyObject *v = (PyObject*)interp->small_ints[ival + NSMALLNEGINTS];
Py_INCREF(v);
return v;

 

Python Console 및 Script의 결과

그런데 한 가지 재미있는 사실은 아래 코드를 어떤 환경에서 실행하는지에 따라 결과가 다르다는 것이다.

a = 257 
b = 257 
print(a is b)

위에서 살펴본 바에 따르면 a, b는 [-5, 256] 범위 밖의 숫자이므로 서로 다른 instance의 reference일 것이고, 따라서 False가 출력되어야 한다. 그런데 위의 코드는 Python interactive console에서 실행했을 경우에는 False, script file로 작성해 실행했을 경우에는 True 출력된다.

이것은 코드를 파일로 저장해 실행했을 경우 Python 컴파일러가 코드를 분석하고 bytecode를 최적화하기 때문이다. 아래와 같이 간단한 테스트 코드를 작성해 이 사실을 확인할 수 있다.

>>> import dis
>>> def test():
...     a = 257
...     b = 257
...     print(a is b)
...     
>>> dis.dis(test)
  2           0 LOAD_CONST               1 (257)
              2 STORE_FAST               0 (a)

  3           4 LOAD_CONST               1 (257)
              6 STORE_FAST               1 (b)

  4           8 LOAD_GLOBAL              0 (print)
             10 LOAD_FAST                0 (a)
             12 LOAD_FAST                1 (b)
             14 COMPARE_OP               8 (is)
             16 CALL_FUNCTION            1
             18 POP_TOP
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

 

Disassemble 된 bytecode를 보면 a, b의 값을 비교할 때 상수(constant) 257을 그대로 가져와 직접 비교하는 방식으로 동작하는 것을 볼 수 있다.

 

참고:
- https://stackoverflow.com/questions/306313/is-operator-behaves-unexpectedly-with-integers
- https://stackoverflow.com/questions/15171695/whats-with-the-integer-cache-maintained-by-the-interpreter

Share this post!

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.