[입 개발] 신묘한 Python locals() 의 세계

오늘도 약을 팔러온 입개발 CharSyam 입니다. 오늘은 지인 분께서, Python에서 locals() 함수를 쓰면 local 변수를 참조할 수 있는데, 특정 현상은 이해가 안된다고 얘기를 하셔서, 한번 왜 그럴까에 꽃혀서 찾아본 내용을 정리할려고 합니다. 참고로, Python 쓰시는데 이런게 있구나 빼놓고는 아마 하등의 도움을 못 받으실 내용이니, 조용히 뒤로 가기 버튼을 눌리시고, 생산적인 페이스북을 하시는게 더 도움이 되실꺼라고 미리 경고드립니다.

문제의 시작의 발단은 아래의 코드입니다.

def test(x):
    locals()['v'] = x + 10
    print(locals()['x'])
    return locals()['v']

print(test(100))

locals() 함수는 현재의 local 변수를 dict type으로 던져주는 내장 함수입니다.
그리고 사실 Python2, 3 문서에 보면 locals() 의 값은 고칠 수 없다라고 정의가 되어 있습니다.
https://docs.python.org/3.3/library/functions.html#locals
스크린샷 2018-05-04 오전 12.31.42

그런데 위에서 보면 locals()[‘x’]의 경우는 로컬 변수 x의 값을 가져오고, locals()[‘v’]를 하면 v가 추가가 됩니다. 즉 해당 변수안에서 새로운 값을 이 locals()에 추가하는 것은 된다는 것입니다. 그렇다고 해서 그냥 v로 쓸 수 있지는 않고 locals()[‘v’] 이런식으로만 사용이 가능합니다.

일단 locals()[‘v’] = 100 이라는 코드 자체는 locals()의 결과가 dict type이므로 전혀 문제가 없는 코드 입니다. 특별히 읽기 전용 속성 같은게 있는 것도 아니구요. 그리고 다시 locals()[‘v’]를 사용했을 때 해당 값을 가져올 수 있다는 것은, locals()가 같은 객체를 던져준다는 것입니다. id(locals()) 해보면 항상 같은 주소값을 던져줍니다.

즉 우리는 다음과 같은 가설을 세울 수 있습니다. 가정1) locals()에 값이 저장이 된다.
그럼 insert 가 되니 update 도 되지 않을까요? 당연히 아래와 같은 코드는 update도 잘됩니다. locals()의 결과는 dict type 의 객체일 테니…

def test(x):
    locals()['v'] = x + 10
    locals()['v'] = 1
    return locals()['v']

print(test(100))

그런데 다음과 같은 코드가 출동하며 어떨까요?

def test(x):
    locals()['x'] = 10
    return locals()['x']

print(test(100))

우리는 이미 답을 알고 있습니다. 처음 문서에 수정이 안된다고 했으니 당연히 결과는 100이 나오게 됩니다. 뭔가 이상하지 않나요? locals()의 결과는 그냥 dict type이고, 그래서 새로운 값을 추가하는 것도 분명히 되는데, 업데이트는 안됩니다. 뭔가 locals()는 특별하게 만든 기능이라, dict type 자체에서 set을 막고 있는 것일까요?

일단은 먼저 Python 2.7.14 기준으로 설명합니다. Objects/dictobject.c 의 PyDict_SetItem 함수를 보면 특별히 특정 속성일 때 쓰기를 막는다 이런코드는 보이지 않습니다. dict_set_item_by_hash_or_entry 안으로 들어가봐도 큰 차이는 없습니다.

int
PyDict_SetItem(register PyObject *op, PyObject *key, PyObject *value)
{
    register long hash;

    if (!PyDict_Check(op)) {
        PyErr_BadInternalCall();
        return -1;
    }
    assert(key);
    assert(value);
    if (PyString_CheckExact(key)) {
        hash = ((PyStringObject *)key)->ob_shash;
        if (hash == -1)
            hash = PyObject_Hash(key);
    }
    else {
        hash = PyObject_Hash(key);
        if (hash == -1)
            return -1;
    }
    return dict_set_item_by_hash_or_entry(op, key, hash, NULL, value);
}

그렇다면 dict type 자체의 이슈가 아니라 locals() 의 이슈가 아닐까라는 생각이 들게 됩니다. 제가 위에서 locals()는 내장함수라고 말씀드렸습니다. 그래서 Python/bltinmodule.c 를 살펴보면 builtin_locals를 찾을 수 가 있습니다. builtin_methods 테이블에 locals과 builtin_locals와 연결되어 있는 것을 찾을 수 있습니다.

static PyObject *
builtin_locals(PyObject *self)
{
    PyObject *d;
    d = PyEval_GetLocals();
    Py_XINCREF(d);
    return d;
}

그냥 PyEval_GetLocals() 만 호출합니다. Python/ceval.c 에 있습니다. 다시 따라가 봅시다.

PyObject *
PyEval_GetLocals(void)
{
    PyFrameObject *current_frame = PyEval_GetFrame();
    if (current_frame == NULL)
        return NULL;
    PyFrame_FastToLocals(current_frame);
    return current_frame->f_locals;
}

뭔가 이름이 있어 보이는 PyFrame_FastToLocals 함수가 보입니다. current_frame 은 뭔지는 잘 모르겠지만, locals니, 특정 scope에서의 코드 정보(함수안이냐, 글로벌이냐?)를 가져오는 걸로 예측이 됩니다.

다시 PyFrame_FastToLocals 를 따라가 봅시다. Objects/frameobject.c 에 존재합니다.

void
PyFrame_FastToLocals(PyFrameObject *f)
{
    /* Merge fast locals into f->f_locals */
    PyObject *locals, *map;
    PyObject **fast;
    PyObject *error_type, *error_value, *error_traceback;
    PyCodeObject *co;
    Py_ssize_t j;
    int ncells, nfreevars;
    if (f == NULL)
        return;
    locals = f->f_locals;
    if (locals == NULL) {
        locals = f->f_locals = PyDict_New();
        if (locals == NULL) {
            PyErr_Clear(); /* Can't report it 😦 */
            return;
        }
    }
    co = f->f_code;
    map = co->co_varnames;
    if (!PyTuple_Check(map))
        return;

    PyErr_Fetch(&error_type, &error_value, &error_traceback);
    fast = f->f_localsplus;
    j = PyTuple_GET_SIZE(map);
    if (j > co->co_nlocals)
        j = co->co_nlocals;

    if (co->co_nlocals)
        map_to_dict(map, j, locals, fast, 0);
    ncells = PyTuple_GET_SIZE(co->co_cellvars);
    nfreevars = PyTuple_GET_SIZE(co->co_freevars);
    fprintf(stderr, "co_nlocals: %d, ncells : %d, nfreevars : %d\n", co->co_nlocals, ncells, nfreevars);
    if (ncells || nfreevars) {
        fprintf(stderr, "map_to_dict1()\n");
        map_to_dict(co->co_cellvars, ncells,
                    locals, fast + co->co_nlocals, 1);
        /* If the namespace is unoptimized, then one of the
           following cases applies:
           1. It does not contain free variables, because it
              uses import * or is a top-level namespace.
           2. It is a class namespace.
           We don't want to accidentally copy free variables
           into the locals dict used by the class.
        */
        if (co->co_flags & CO_OPTIMIZED) {
            fprintf(stderr, "map_to_dict2()\n");
            map_to_dict(co->co_freevars, nfreevars,
                        locals, fast + co->co_nlocals + ncells, 1);
        }
    }
    PyErr_Restore(error_type, error_value, error_traceback);
}

위의 코드를 보면 f->f_locals 를 locals에 대입하고 없으면 dict type을 생성하는 것을 알 수 있습니다. 우리의 locals()의 결과는 dict type이고 이름까지 비슷하니 이넘이구나 하실껍니다. 그런데 왜 update는 안되는 것일까요?

그 의문은 다음 블로그에서… 는 뻥이고… co->co_nlocals 라는 변수에 있습니다. co는 codeobject 의 약어로 보이고, codeobject 는 해당 함수 관련 정보(global 변수 정보, local 변수 정보)를 가지고 있는 것으로 보입니다.(대충 봐서) 그 중에서 co_nlocals 는 local 변수의 개수를 가지고 있습니다. 즉 local 변수가 한개라도 있으면 map_to_dict 이 실행됩니다. 저는 처음에는 map_to_dict 에서 뭔가 locals 를 만들어 주는 줄 알았습니다. 그런데 map_to_dict 함수를 보면 map 에 있는 key를 dict에다가 PyObject_SetItem 를 통해서 덮어씌워버립니다. 그리고 저 map은 코드가 실행될때 넘어온 파라매터나 로컬 변수의 값들이 저장되어 있는 상황입니다.

static void
map_to_dict(PyObject *map, Py_ssize_t nmap, PyObject *dict, PyObject **values,
            int deref)
{
    Py_ssize_t j;
    assert(PyTuple_Check(map));
    assert(PyDict_Check(dict));
    assert(PyTuple_Size(map) >= nmap);
    for (j = nmap; --j >= 0; ) {
        PyObject *key = PyTuple_GET_ITEM(map, j);
        PyObject *value = values[j];

        assert(PyString_Check(key));
        if (deref) {
            assert(PyCell_Check(value));
            value = PyCell_GET(value);
        }
        if (value == NULL) {
            if (PyObject_DelItem(dict, key) != 0)
                PyErr_Clear();
        }
        else {
            if (PyObject_SetItem(dict, key, value) != 0)
                PyErr_Clear();
        }
    }
}

헉, 하고, 이해를 하신 분들이 이미 계실꺼 같지만, 넵 그렇습니다. locals()를 호출할 때 아까 x의 값이 안바뀐 이유는 실제로 바뀌어도 locals()를 다시 호출할 때 map_to_dict 함수를 통해서 원래의 값으로 덮어씌워지기 때문입니다. 그래서 다른값들은 같은 locals를 사용하므로 추가나 변경, 삭제도 가능하지만, 기존에 존재하는 값들은 다시 원래의 값으로 덮어씌워지기 때문입니다.

def test(x):
    x = 15
    locals()['x'] = 1
    return locals()['x']

print(test(100))

그래서 local 변수면 동일하게 동작하므로 파라매터가 아니고 그냥 내부에서 미리 선언된 local 변수라도 이렇게 값이 바뀌지 않습니다.

def test():
    x = 15
    locals()['x'] = 1
    return locals()['x']

print(test())

우와 Python 의 locals()는 참 신묘합니다. 그런데… 문제는 여기서 끝이 아닙니다.(아니 도대체 쓸데 없는 이야기를 얼마나 더 할려고?) 그냥 여기까지 이해하고 넘어가려고 하는데… PyFrame_FastToLocals 함수 밑과 위에 map_to_dict 말고 dict_to_map 과 PyFrame_LocalsToFast 라는 함수가 있는게 아니겠습니까?

먼저 dict_to_map 함수입니다.

static void
dict_to_map(PyObject *map, Py_ssize_t nmap, PyObject *dict, PyObject **values,
            int deref, int clear)
{
    Py_ssize_t j;
    assert(PyTuple_Check(map));
    assert(PyDict_Check(dict));
    assert(PyTuple_Size(map) >= nmap);
    for (j = nmap; --j >= 0; ) {
        PyObject *key = PyTuple_GET_ITEM(map, j);
        PyObject *value = PyObject_GetItem(dict, key);
        assert(PyString_Check(key));
        /* We only care about NULLs if clear is true. */

        if (value == NULL) {
            PyErr_Clear();
            if (!clear)
                continue;
        }
        if (deref) {
            assert(PyCell_Check(values[j]));
            if (PyCell_GET(values[j]) != value) {
                if (PyCell_Set(values[j], value) f_locals into fast locals */
    PyObject *locals, *map;
    PyObject **fast;
    PyObject *error_type, *error_value, *error_traceback;
    PyCodeObject *co;
    Py_ssize_t j;
    int ncells, nfreevars;
    if (f == NULL)
        return;
    locals = f->f_locals;
    co = f->f_code;
    map = co->co_varnames;
    if (locals == NULL)
        return;
    if (!PyTuple_Check(map))
        return;
    PyErr_Fetch(&error_type, &error_value, &error_traceback);
    fast = f->f_localsplus;
    j = PyTuple_GET_SIZE(map);
    if (j > co->co_nlocals)
        j = co->co_nlocals;
    if (co->co_nlocals)
        dict_to_map(co->co_varnames, j, locals, fast, 0, clear);
    ncells = PyTuple_GET_SIZE(co->co_cellvars);
    nfreevars = PyTuple_GET_SIZE(co->co_freevars);
    if (ncells || nfreevars) {
        dict_to_map(co->co_cellvars, ncells,
                    locals, fast + co->co_nlocals, 1, clear);
        /* Same test as in PyFrame_FastToLocals() above. */
        if (co->co_flags & CO_OPTIMIZED) {
            dict_to_map(co->co_freevars, nfreevars,
                locals, fast + co->co_nlocals + ncells, 1,
                clear);
        }
    }
    PyErr_Restore(error_type, error_value, error_traceback);
}

다음은 PyFrame_LocalsToFast 함수입니다.

void
PyFrame_LocalsToFast(PyFrameObject *f, int clear)
{
    /* Merge f->f_locals into fast locals */
    PyObject *locals, *map;
    PyObject **fast;
    PyObject *error_type, *error_value, *error_traceback;
    PyCodeObject *co;
    Py_ssize_t j;
    int ncells, nfreevars;
    if (f == NULL)
        return;
    locals = f->f_locals;
    co = f->f_code;
    map = co->co_varnames;
    if (locals == NULL)
        return;
    if (!PyTuple_Check(map))
        return;
    PyErr_Fetch(&error_type, &error_value, &error_traceback);
    fast = f->f_localsplus;
    j = PyTuple_GET_SIZE(map);
    if (j > co->co_nlocals)
        j = co->co_nlocals;
    if (co->co_nlocals)
        dict_to_map(co->co_varnames, j, locals, fast, 0, clear);
    ncells = PyTuple_GET_SIZE(co->co_cellvars);
    nfreevars = PyTuple_GET_SIZE(co->co_freevars);
    if (ncells || nfreevars) {
        dict_to_map(co->co_cellvars, ncells,
                    locals, fast + co->co_nlocals, 1, clear);
        /* Same test as in PyFrame_FastToLocals() above. */
        if (co->co_flags & CO_OPTIMIZED) {
            dict_to_map(co->co_freevars, nfreevars,
                locals, fast + co->co_nlocals + ncells, 1,
                clear);
        }
    }
    PyErr_Restore(error_type, error_value, error_traceback);
}

PyFrame_FastToLocals 가 원래 locals() 를 호출할 때 실제로 수행되는 함수였다면 PyFrame_LocalsToFast 는 뭔가 역으로 값을 바꿀 수 있는 함수가 아닐까라는 생각을 해봤습니다. 해당 함수를 역으로 살짝 추적해보니(악, 여기서 그만뒀어야 하는데!!!) Python/ceval.c 파일안에 exec_statement 라는 함수에서 이걸 호출해줍니다.

설마설마 하면서 다음과 같은 예제를 만들어봤습니다.

def a(x):
    exec("locals()['x'] = 100")
    print(x)
    return locals()['x']

print(a(10))

python 으로 실행을 시키니 그냥 10만 나옵니다. 안되나? 라고 생각했는데… 제가 보던 소스는 Python 2.7.14이고, 제가 실행시킨 python은 3.x(이래서 머리가 무식하면 손발이 고생합니다.), 다시 python2 버전으로 돌려보시면 짜잔, 값이 100이 나옵니다. 즉 우리는 로컬 변수 x를 덮어씌운겁니다. –-; 그런데 Python3를 살펴보면 이 exec_statement 라는 함수가 사라지고 실제로 PyFrame_LocalsToFast를 호출하기 힘든 형태로 바뀌었습니다. –-;(망할… 즉 python3 에서는 동작하지 않고 python2에서만 실행이 됩니다.)

심지어 이렇게 하면 이런 이상한짓도 가능합니다. 아까는 locals()는 그냥 dict_type이라 뭔가 추가해도 다시 dict에서 꺼내와야 했는데… 아래의 예제를 사용하면 local 변수의 생성도 가능합니다. -_-(그런데 이렇게 생성할 필요가…, 참고로 Python3에서는 실행조차 안됩니다.)

def a(x):
    exec("locals()['x'] = 100")
    exec("locals()['y'] = 10")
    print(x)
    print(y)
    return locals()['x']

print(a(10))

어쩌다 보니, 소스를 파다보니 이런 내용을 알게 되었는데, 사실 사용하실 때는 1의 도움도 안되는 뻘 글을 읽어주셔서 감사합니다.