2019.01.15 - [컴퓨터 사이언스 부트캠프 with 파이썬] - Study03

8 분 소요

본 포스팅은 컴퓨터 사이언스 부트캠프 with 파이썬 이라는 책을 참고하여 개인 공부를 하면서 정리하고 있습니다. 문제 될 시 삭제하겠습니다.

함수

전역 변수와 지역 변수

전역 변수

전역 변수(global variable)는 전체 영역에서 접근할 수 있는 변수이다. 어느곳에서든 접근이 가능하다

g_var = 10

def func():
    print("g_var = {}".format(g_var))
    
if __name__ == "__main__":
    func()
    
>>> g_var = 10

위의 예제를 통해 보면 g_var이라는 변수가 전역 변수이다. 함수 내부에서 전역 변수에 접근할 수 있는 것을 확인할 수 있다. 하지만 함수 내부에서 전역 변수를 변경하려고 하는 경우 일반적인 변수에 다른 상수값을 넣으면 변경되지 않는다. 확인하는 코드는 다음과 같다.

g_var = 10

def func():
    g_var = 20
    print("g_var = {} in functionn".format(g_var))
    
if __name__ == "__main__":
    func()
    print("g_var = {} in main".format(g_var))
    
>>> g_var = 20 in function
>>> g_var = 10 in main

분명 func함수 내부에서 g_var변수를 20으로 변경해주었는데 함수가 실행되는 순간에만 잠시 변경되었을 뿐 값이 전혀 변경되지 않았다. 사실 이는 함수 실행 순간에 변수가 변경되었었던 것이 아니라 함수 안에서 새로운 지역 변수 g_var을 생성한 것이다. 이름만 같을 뿐 전역 변수 g_var와 지역 변수 g_var는 전혀 다른 함수이다.

지역 변수

지역 변수(local variable)은 전역 변수와는 반대 개념이다. 말 그대로 특정 지역(함수 내부)에서만 접근 할 수 있는 변수이다. 이 지역 변수는 함수 바깥에서는 접근할 수 없고, 함수가 호출될 때 생성되었다가 호출이 끝나면 사라진다.

g_var = 10

def func():
    global g_var
    g_var = 20
    
if __name__ == "__main__":
    print("g_var : {} before".format(g_var))
    func()
    print("g_var : {} after".format(g_var))
    
>>> g_var : 10 before
>>> g_var : 20 after

위 코드는 func함수에서 처음에 global 키워드를 사용하여 전역 변수 g_var변수를 함수 내부에서 사용하겠다고 명시해준다. 따라서 이후에 func함수 내부에서 사용되는 g_var변수는 지역 변수가 아닌 전역 변수이기 때문에 값이 변경된 것을 확인할 수 있다.

nonlocal키워드

a = 1

def outer():
    b = 2
    c = 3
    print(a, b, c)
    def inner():
    d = 4
    e = 5
    print(a, b, c, d, e)
    inner()
    
if __name__ = "__main__":
    outer()
    
>>> 1 2 3
>>> 1 2 3 4 5

위의 코드를 보면 inner()함수에서는 전역 변수 뿐만 아니라 outer()함수의 공간에 있는 지역 변수에도 접근 이 가능한 것을 확인할 수 있다. 하지만 inner()함수 내부에서 b와 c를 바꾸려고 시도하면 outer()함수 공간에 접근하는 것이 아니라 b와 c라는 지역 변수를 생성한다. 이때 앞에서 사용했던 global키워드를 사용하여 b와 c에 접근도 불가능하다. b와 c가 전역 변수는 아니기 때문이다. 이때 사용하는 키워드가 nonlocal 키워드이다.

def outer():
    a = 2
    b = 3
    
    def inner():
        nonlocal a
        a = 100
    inner()
    
    print("locals in outer : a = {}, b = {}".format(a, b))
    
if __name__ == "__main__":
    outer()
    
>>> locals in outer : a = 100, b = 3

인자 전달 방식에 따른 분류

함수는 인자(argument) 전달 방식에 따라 값에 의한 전달(call by value)과 참조에 의한 전달(call by reference)로 나누어진다. 하지만 파이썬은 이 두 전달 방식을 따르지 않고 다른 방식을 사용한다.

값에 의한 전달

값에 의한 전달(call by value)는 인자를 전달할 때 값을 복사해 전달하는 경우를 말한다. 파이썬은 이 값에 의한 전달 방식을 사용하지 않기 때문에 예제를 c++코드로 알아 보자.

#include <iostream>
using namespace std;

void chang_value(int x, int value)
{

    x = value;
    cout << "x : " << x << " in change_value" << endl;
}

int main(void)
{
    
    int x = 10;
    change_value(x, 20);
    cout << "x: " << x << " in main" << endl;
    
    
    return 0;
}

>>> x : 20 in change_value
>>> x : 10 in main

위의 c++코드를 잠깐 설명하면 change_value() 함수는 인자x와 value를 받아 x에 value를 대입한다. 위 코드를 통해 전역 변수와 지역 변수의 개념 없이 이해를 해보면 당연히 change_value() 함수를 호출하면서 value 인자에 20을 전달했기 때문에 지역 변수 x 값을 20으로 바뀔것이라고 예상이 된다. 하지만 실제 결과는 x가 변경되지 않고 출력이된다. 이러한 결과가 나온 이유는 함수에 x가 전달될 때 값에 의한 전달 방식으로 전달되었기 때문이다.

이 값에 의한 전달 방식에 대한 이해를 돕기위해 스택 프레임 개념을 알아야한다. 함수가 호출되고 변수가 선언이되는 여러 과정에서 메모리에는 __스택 프레임__이 생긴다. 이 스택 프레임은 지역 변수가 존재하는 영역이다. 아래의 예시를 통해 스택 프레임의 모습을 보면 다음과 같다.

우리가 짠 코드의 스택 프레임을 확인하고 싶으면 http://pythontutor.com 사이트에서 확인할 수 있다.

#include <iostream>
using namespace std;

int test(int a, int b):


int main(void)
{
    
    int a = 10, b = 5;
    int res = test(a, b);
    cout << "result of test : " << res << endl;
    return 0;
}

int test(int a, int b)
{

    int c = a + b;
    int d = a - b;
    return c + d;
}

main()함수가 먼저 실행되므로 스택 프레임이 먼저 쌓이고 main()함수 안에서 호출한 test()함수의 스택 프레임은 그 위에 쌓인다. test()함수가 모두 실행되면 test()함수의 스택 프레임이 먼저 사라지고 이후 프로그램이 종료되면 main()함수의 스택 프레임이 사라진다. 이 두 함수 main()함수와 test()함수의 공간은 서로 독립된 공간이다. 인자를 전달할 때 main()함수 스택 프레임이 지역 변수인 a와 b를 전달한것 같지만, 실제로는 test() 함수 스택 프레임의 지역 변수 a와 b에 값만 ‘복사’한 것이다. 이렇게 인자를 전달할 때 값을 복사해 전달하는 경우를 값에 의한 전달이라고 한다.

change_value의 스택 프레임1

일단 위 코드가 작동되는 원리를 하나씩 뜯어보자. 먼저 change_value()함수 스택 프레임의 x와 main()함수의 스택 프레임의 x는 서로다른 메모리 공간에 존재하는 서로 다른 변수이다. 값만 10으로 같다. test()함수가 실행되고 나면 x에 value값을 대입했으므로 x 값은 20으로 변한다. 하지만 main()함수의 x와는 다른 변수이기 때문에 main()함수의 x값은 변하지 않는다. change_value()함수의 스택 프레임이 지역 변수 x값인 20을 출력하고 사라진다. 이 상태에서 x값을 출력하면 main()함수의 x값인 10이 나온다.

change_value의 스택 프레임2

참조에 의한 전달

위의 코드에서 함수의 호출로 main()함수의 x값을 변경하고 싶은 경우에는 참조에 의한 전달 방식으로 인자를 전달하면 된다. 참조에 의한 전달(call by reference) 방식은 인자를 전달할 때 값을 전달하는 게 아니라 참조를 전달한다. 코드는 다음과 같다.

#include <iostream>
using namespace std;

void change_value(int *x, int value)
{

    *x = value;
    cout << "x : " << *x << " in change_value" << endl;
}


int main(void)
{

    int x = 10;
    change_value(&x, 20);
    cout << "x : " << x << " in main" << endl;
    return 0;
}

>>> x : 20 in change_value
>>> x : 20 in main

값에 의한 전달 코드와 위 코드의 차이를 보면 \&x로 인자를 전달한다는 것이다. 이는 main()함수 스택 프레임의 변수 x가 위치한 메모리 공간의 첫 번째 바이트 주소 값을 전달한다는 의미이다. 즉, 값 10을 전달하는 것이 아니라 10을 저장하고 있는 메모리 공간 속 4바이트 공간 중 첫번째 주소 값을 전달한다. 그리고 *x는 포인터 변수를 의미한다. 포인터 변수도 다른 변수처럼 데이터를 저장하지만 메모리 주소를 데이터 값으로 저장한다.

스택 프레임1

change_value()함수 스택 프레임의 포인터 변수 x는 \&x를 통해 change_value(\&x, 20)에서 전달된 main()함수 스택 프레임 안에 지역 변수 x의 주소 값을 저장한다. 포인터 변수가 주소 값을 저장한다는 것은 change_value 스택 프레임 안에 있는 int형 포인터 x가 화살표를 따라 main()함수의 지역 변수 x를 가리키는 것과 같다.

스택 프레임1

*x = value에서 *x를 역참조(dereference)라고 하며 x에 저장된 주소 값인 0x1111 1111로 접근한다. 이렇게 주소값에 접근하여 value를 대입하면 main()함수의 지역 변수 x가 있는 메모리 공간에 직접 value값을 대입할 수 있다.

객체 참조에 의한 전달(파이썬)

파이썬은 객체 참조에 의한 전달(call by object reference)이라는 특별한 방식으로 인자를 전달한다.

변경 불가능 객체를 전달하는 경우

def change_value(x, value):
    x = value
    print("x : {} in change_value".format(x))
    
if __name__ == "__main__":
    x = 10
    change_value(x, 20)
    print("x : {} in main".format(x))
    
>>> x : 20 in change_value
>>> x : 10 in main

위 코드는 아래의 순서대로 스택 프레임이 생성 되었다가 사라진다.

위의 그림은 함수 change_value안에 x = value가 실행되기 전의 모습이다.

함수 change_value안에 x = value가 실행되고 나면 다음과 같이 변경된다. 파이썬에서 상수 객체는 변경 불가능한 객체이다. 따라서 변수 값을 바꾼다는 의미는 변수 이름이 가리키는 메모리 공간의 값을 직접 바꾸는 것이 아니라 바꾸고자 하는 상수 객체를 참조하는 것이다.

따라서 기존에 있던 전역 변수 x값은 변경되지 않고 change_value함수 안에 지역 변수 x의 값만 참조를 변경하여 값이 다르게 나오는 것이다.

파이썬은 레퍼런스 카운팅으로 가비지 컬렉션을 구현한다. 가비지 컬렉션은 더이상 사용하지 않는 메모리를 언어 차원에서 해제하는 개념을 말하고, 레퍼런스 카운팅은 어떠한 객체가 참조 받고 있는 수를 세는 것을 말한다. 파이썬에서는 이 레퍼런스 카운팅이 0이 되는 순간 메모리에서 해제시킨다.

변경 가능 객체를 전달하는 경우

def func(li):
    li[0] = 'I am your father!'
    
if __name__ == "__main__":
    li = [1, 2, 3, 4]
    func(li)
    print(li)
   
    
>>> ['I am your father!', 2, 3, 4]

함수 내부에서 리스트 li[0] 값을 변경하는 코드이다. 함수를 호출한 쪽에서 리스트를 출력하면 이전에 봤던 것처럼 변경이 되지 않아야한다. 하지만 결과 값을 보면 변경이 되어있다.

def func(li):
    li = ['I am your fater', 2, 3, 4]
    
if __name__ == "__main__":
    li = [1, 2, 3, 4]
    func(li)
    print(li)
    
    
>>> [1, 2, 3, 4]

이번에는 리스트가 변경이 되지 않았다. 두 코드 사이에는 다음과 같은 차이가 있다.

  • 참조한 리스트에 접근해 변경을 시도
  • 아예 다른 리스트를 메모리 공간에 새로 만든 다음 이를 참조해 리스트를 변경

먼저 첫번째 코드를 그림을 통해 알아보면 다음과 같다.

첫번째 코드

먼저 리스트는 변경 가능 객체이다.(상수와 튜플은 변경 불가능 객체) 리스트의 첫번째 요소 값을 변경할 때 값 객체만 새로운 공간에 만들어 참조하면 된다. 즉, 값을 변경하기 위해 리스트 자체를 다른 메모리 공간에 새로 할당할 필요가 없다.

두번째 코드

이번에는 다른 메모리 공간에 새로운 리스트를 만들어 li로 참조를 한다. 요소가 아니라 리스트 자체를 변경한 것이다. 이 경우 함수 호출이 끝나면 func함수의 스택 프레임이 사라지면서 새로 만들어진 리스트는 삭제된다. 따라서 main의 지역 변수인 리스트 li는 변경되지 않는다.

객체 참조에 의한 전달 방식을 정리하면 다음과 같다.

  • 함수 인자로 변경 불가능 객체를 전달해 값을 변경할 수 없다. 함수 안에서 새 객체를 만든 다음 참조하여 바꾸려 하면 함수 호출이 끝나고 스택 프레임도 사라지면서 참조도 사라지기 때문이다.
  • 함수 내부에서 객체를 새롭게 할당해야만 값을 변경할 수 있는 객체는 변경 불가능 객체인 상수, 문자열, 튜플뿐이다.
  • 리스트나 딕셔너리 같은 변경 가능 객체도 함수 안에서 새로운 객체를 만들 경우 함수 호출이 끝나면서 객체는 사라진다.
  • 변경 가능 객체를 인자로 전달할 때도 인자로 전달된 객체에 접근하여 변경해야만 함수를 호출한 쪽의 객체를 변경할 수 있다.

변경 불가능 객체인 튜플을 함수 인자로 변경하기 위해서는 함수의 반환 값을 통해서 함수 인자를 전달하면 된다.

def change_value(tu):
    tu = ('I am your father!', 2, 3, 4)
    return tu
    
if __name__ == "__main__":
    tu = (1, 2, 3, 4)
    tu = change_value(tu)
    print(tu)
    
>>> ('I am your father!', 2, 3, 4)

파이썬의 얕은 복사와 깊은 복사