-
프로그래밍을 하다 보면 똑같은 내용을 반복해서 작성하고 있는 자신을 발견할 때가 종종 있다. 이때가 바로 함수가 필요한 때이다. 즉 반복되는 부분이 있을 경우 "반복적으로 사용되는 가치 있는 부분"을 한 뭉치로 묶어서 "어떤 입력값을 주었을 때 어떤 결괏값을 돌려준다"라는 식의 함수로 작성하는 것이 현명하다.
함수를 사용하는 또 다른 이유는 자신이 만든 프로그램을 함수화하면 프로그램 흐름을 일목요연하게 볼 수 있기 때문이다. 마치 공장에서 원재료가 여러 공정을 거쳐 하나의 상품이 되는 것처럼 프로그램에서도 입력한 값이 여러 함수를 거치면서 원하는 결괏값을 내는 것을 볼 수 있다. 이렇게 되면 프로그램 흐름도 잘 파악할 수 있고 오류가 어디에서 나는지도 바로 알아차릴 수 있다. 함수를 잘 사용하고 함수를 적절하게 만들 줄 아는 사람이 능력 있는 프로그래머이다. (참조: Jump to python)함수의 구조
def 함수명(매개변수): <수행할 문장>
매개변수와 인수
매개변수(parameter)와 인수(argument)는 혼용해서 사용되는 용어지만, parameter는 함수에 입력으로 전달된 값을 받는 변수를 의미하고 argument는 함수를 호출할 때 전달하는 입력값을 의미한다.
def add(x, y): # x, y는 매개변수(parameter) return x + y add(3, 4) # 3, 4는 인수(argument)
전역 변수
전역 변수(global varable)는 전체 영역에서 접근할 수 있는 변수다. 따라서 함수 안에서도 접근할 수 있어야 한다. 예제를 살펴보자.
g_var = 10 def func(): print("g_var = {0}".format(g_var)) if __name__ == "__main__": func()
#실행 결과 g_var = 10
위의 코드에서 g_var은 전역 변수다. 함수 안에서 전역 변수에 접근했고, 실행 결과를 보면 접근이 가능하다는 것을 알 수 있다. 이번에는 함수 안에서 전역 변수의 변경을 시도해 보자.
g_var = 10 def func(): g_var = 20 print("g_var = {0} in function".format(g_var)) if __name__ == "__main__": func() print("g_var = {0} in main".format(g_var))
#실행 결과 g_var = 20 in function g_var = 10 in main
함수 안에서 전역 변수 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
global 키워드를 이용해 전역 변수 g_var을 함수 안에서 사용하겠다고 명시했다.
nonlocal 키워드
함수를 정의할 때 함수 내부에서 다른 함수를 정의할 수 있다.
def outer(): a = 10 #1 def inner(): b = 20 #2
코드에서 outer() 함수에 지역 변수 a가 선언되었고, 중첩된 함수 inner() 함수에 지역 변수 b가 선언되었다. inner() 함수에서 outer() 함수의 지역 변수인 a를 변경할 수 있을까?
변수 a는 outer() 함수 입장에서는 지역 변수이지만 inner() 함수 입장에서는 지역 변수가 아니다. inner() 함수의 지역 변수는 b이다.
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()
#실행 결과 123 12345
위의 코드를 보면 outer() 함수의 공간에 b와 c가 있고 중첩된 inner() 함수의 공간에는 d와 e가 있다. inner() 함수에서는 전역 변수뿐만 아니라 outer() 함수의 공간에 있는 지역 변수에도 접근할 수 있다. 하지만 전역 변수 예제에서 살펴본 것처럼 inner() 함수 안에서 b와 c를 바꾸려고 시도하면 outer() 함수 공간에 접근하는 것이 아니라 inner() 함수 공간 안에 b와 c라는 지역 변수를 생성한다. b나 c는 전역 변수가 아니니 global 키워드를 사용할 수도 없다. inner() 함수 안에서 b와 c를 변경하려면 어떻게 해야 할까.
nonlocal 키워드를 사용하면 된다.
def outer(): a = 2 b = 3 def inner(): nonlocal a a = 100 inner() print("local in outer : a = {0}, b = {1}".format(a, b)) if __name__ == "__main__": outer()
#실험 결과 locals in outer : a = 100, b = 3
인자 전달 방식에 따른 분류
함수는 인자(argument) 전달 방식에 따라 크게 값에 의한 전달(call by value)과 참조에 의한 전달(call by reference)로 나뉜다.
(파이썬은 값에 의한 전달과 참조에 의한 전달 방식을 이용하지 않으므로 c++코드로 작성한다.)
-값에 의한 전달 call by value
#코드 1-1 #include <iostream> using namespace std; void change_value(int x, int value) #1 { x = value; #2 cout << "x : " << x << " in change_value " << end1; } int main(void) { int x = 10; #3 change_value(x, 20); #4 cout << "x : " << x << "in main" << end1; return 0; }
#실험 결과 x : 20 in change_value x : 10 in main
위의 코드를 보면 main() 함수에서 지역 변수 x에 10을 대입한 다음 change_value() 함수를 호출하면서 value 인자로 20을 전달했으므로 지역 변수 x값은 20으로 바뀔 듯 하지만 실행결과는 예상과 다르다.
지역 변수 x가 변경되지 않은 이유는 함수에 x가 전달될 때 값에 의한 전달 방식으로 전달되었기 때문이다.
함수가 호출될 때 메모리에는 '스택 프레임'이 생긴다. 스택 프레임은 함수의 메모리 공간 즉, 지역 변수가 존재하는 영역이다. 간단한 함수를 정의하고 함수가 호출될 때 스택 프레임의 모습을 살펴보자.
#코드 1-2 #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 << end1; return 0 } int test(int a, int b) { int c = a + b; int d = a - b; return c + d; }
마지막에 있는 함수 정의와 main() 함수에서 test() 함수를 호출하는 부분을 보면, test() 함수는 인자로 a와 b를 받고, 지역 변수로 c와 d를 선언한다. 함수를 호출하면 아래 그림과 같은 스택 프레임이 메모리에 생긴다.
(스택 프레임에는 함수를 호출한 다음 복귀할 주소 값 등 지역 변수 이외의 정보도 담고 있다.)
main() 함수도 함수이므로 스택 프레임을 갖고 있다. 아래의 그림은 스택 프레임을 test() 함수를 호출한 main() 함수까지 확장한 모습이다.
스택 프레임은 스택 메모리 공간에 생기는데 이 공간 역시 스택 자료 구조의 작동 원리를 따른다. main() 함수가 먼저 실행되므로 스택 프레임이 먼저 쌓이고 main() 함수 안에서 호출한 test() 함수의 스택 프레임은 그 위에 쌓인다. test() 함수가 모두 실행되면 test() 함수의 스택 프레임이 먼저 사라지고 이후에 프로그램이 종료되면 main() 함수의 스택 프레임이 사라진다.
그림 1-2를 보면 main() 함수의 스택 프레임 공간에도 a와 b가 있고 test() 함수의 스택 프레임 공간에도 a와 b가 있다. 이 공간은 서로 독립된 공간이다. 코드에서 인자를 전달할 때 main() 함수 스택 프레임의 지역 변수인 a와 b를 전달할 것 같지만, 실제로는 test() 함수 스택 프레임의 지역 변수 a와 b에 값만 '복사'한 것이다. 이처럼 인자를 전달할 때 값을 복사해 전달하는 경우를 값에 의한 전달이라고 한다. 말 그대로 값을 복사할 뿐이다.
이번에는 코드 1-1의 change_value() 함수를 호출할 때의 스택 프레임을 그려보고, main() 함수의 지역 변수 x가 변경되지 않는 이유를 알아보자.
그림 1-3은 코드 1-1의 #2가 실행되기 직전에 본 스택 프레임 모습이다.
change_value() 함수 스택 프레임의 x와 main() 함스 스택 프레임의 x는 서로 다른 메모리 공간에 존재하는 서로 다른 변수다. 값만 10으로 같을 뿐이다. #2 코드가 실행된 다음에 스택 프레임은 그림 1-4와 같다.
x에 value 값을 대입했으므로 x 값은 20이다. 주목할 점은 서로 다른 변수이므로 main() 함수의 지역 변수 x값은 변하지 않았다는 점이다. chaing_value() 함수는 chaing_value 스택 프레임의 지역 변수 x값인 20을 출력하고, 실행이 끝나면 스택 프레임은 사라진다. 그림 1-5는 chainge_value() 함수의 호출을 완료한 다음에 살펴본 스택 프레임의 모습니다.
이 상태에서 x값을 출력하면 10이 출력된다. 이제 main() 함수 안의 지역 변수 x가 change_value() 함수 호출 후에도 값이 변경되지 않는 이유는, 인자를 값에 의한 전달 방식으로 전달했기 때문이란 걸 알았다.
-참조에 의한 전달 call by reference
참조에 의한 전달 방식은 인자를 전달할 때 값을 전달하는 게 아니라 참조를 전달한다.
#코드 1-3 #include <iostream> using namespace std; void change_value(int *x, int value) #1 { *x = value; #2 cout << "x : " << *x << "in change_value" << end1; } int main(void) { int x = 10; #3 change_value(&x, 20); #4 cout << "x : " << x << " in main" << end1; return 0; }
#실행 결과 x : 20 in change_value x : 20 in main
코드 1-3과 코드 1-1을 비교해 보면 함수 인자 목록에서 int x가 int *x로 바뀌었고, x = value;가 *x = value;로 바뀌었다. (*연산자가 추가). 또한 change_value(x, 20)이 change_value(&x, 20)으로 바뀌었다. (&연산자 추가)
int *는 포인터를 의미한다. 그림 1-6은 change_value() 함수를 호출했을 때 *x = value가 실행되기 직전의 스택 프레임 모습이다.
#4에서 &x로 인자를 전달한다. 이는 main() 함수 스택 프레임의 변수 x가 위치한 메모리 공간의 첫 번째 바이트 주소 값을 전달한다는 의미다. 즉, 값 10을 전달하는 게 아니라 데이터 10을 저장하고 있는 4바이트 공간(int형) 중 첫 번째 바이트의 주소 값을 전달한다.
#1의 인자 목록에서 int *x는 포인터 변수를 의미한다. 포인터 변수도 다른 변수처럼 데이터를 저장한다. (메모리 주소를 저장) change_value() 함수 스택 프레임의 포인터 변수 x는 &x를 통해 #4에서 전달된 main() 함수 스택 프레임 안의 지역 변수 x의 주소 값을 저장한다.
포인터 변수가 주소 값을 저장한다는 것은 그림 1-6에서 change_value 스택 프레임 안에 있는 int형 포인터 x가 화살표를 따라 main() 함수의 지역 변수 x를 가리키는 것과 같은 의미다. 가리킨다는 말을 다른 말로 풀면 참조(reference)다. 이렇게 인자로 변수의 참조를 전달하는 방식을 참조에 의한 전달이라고 한다.
#2에서 *x를 역참조(dereference)라고 하며 x에 저장된 주소 값인 0x1111 1111로 접근한다. 이렇게 접근하여 value를 대입하면 main() 함수의 지역 변수 x가 있는 메모리 공간에 value 값을 대입할 수 있다. 그림 1-7을 보면 main 스택 프레임의 x값이 10에서 20으로 바뀐 것을 확인할 수 있다.
change_value() 함수의 호출이 끝나면 change_value() 스택 프레임은 사라진다.
객체 참조에 의한 전달(파이썬) - 변경 불가능 객체를 전달할 때
파이썬은 객체 참조에 의한 전달(call by object reference)이라는 특별한 방식으로 인자를 전달한다. 파이썬에서는 함수를 호출할 때 인자로 전달된 객체를 일단 참조한다.
#코드 2-1 def change_value(x, value): #3 x = value #4 print("x : {0} in change_value".format(x)) if __name__ == "__main__": x = 10 #1 change_value(x, 20) #2 print("x : {0} in main".format(x))
#실행 결과 x : 20 in change_value x : 10 in main
#2에서 change_value() 함수를 호출하면서 인자로 #1의 x를 전달한다. 이때 change_value 스택 프레임이 생성되면서 #3의 인자 x는 함수를 호출한 영역에 있는 #1의 x를 참조한다. 그림 2-1은 #4를 실행하기 전의 스택 프레임이다.
주목할 점은 파이썬의 변수는 C 언어처럼 변수라는 메모리 공간에 값을 직접 저장하지 않는다는 것이다. 변수 이름이 값 객체를 가리키는 것을 알 수 있다. 파이썬 역시 참조에 의한 전달 방식을 쓰는 것처럼 보이지만 출력 결과를 보면 아니라는 것을 알 수 있다. 함수 스택 프레임 안에서는 x 값이 변경되었지만, 함수를 호출한 쪽에서는 x 값이 변경되지 않았다. 그림 2-2는 #4를 실행한 모습이다.
상수 객체는 변경 불가능 객체다. 변수 값을 바꾼다는 의미는 변수 이름이 가리키는 메모리 공간의 값을 직접 바꾸는 게 아니라 바꾸고자 하는 상수 객체를 참조하는 것이다. #4는 x가 value가 가리키는 상수 객체를 참조하게 하는 코드다.
레퍼런스 카운트란? 메모리 영역 중에 힙(heap)이라는 공간이 있다. C/C++에서는 힙에 할당한 메모리는 프로그래머가 직접 해제해야 한다. 하지만 자바, C#, 파이썬 등에서는 메모리를 프로그래머가 관리하지 않고 해당 언어가 스스로 해제한다. 더는 사용하지 않는 메모리를 언어 차원에서 해제한다는 개념을 가비지 컬렉션(garbage collection)이라고 한다.
그렇다면 프로그래밍 언어는 가비지 컬랙션을 어떻게 구현할까? 가장 단순한 형태인 Mark and Sweep부터 가장 빠르다고 알려진 Stop and Copy, Reference Counting 등 가비지 컬렉션을 구현하는 알고리즘에는 여러 가지가 있다.
파이썬은 레퍼런스 카운팅으로 가비지 컬렉션을 구현한다. 여기서 레퍼런스는 참조 즉, 무언가를 가리킨다는 의미다. 파이썬에서 변수는 값을 직접 갖는 게 아니라 상수 객체를 가리키고 있다고 했는데, 이러한 개념이 바로 참조다.
예를 들어 변수 a가 상수 객체를 가리킨다고 가정하자. 가리키는 대상의 개수인 레퍼런스 카운트는 1이다. 이때 b=a라는 코드를 입력하면 변수 b도 10이라는 상수 객체를 가리키게 된다. 그러면 상수 객체 10의 래퍼런스 카운트는 2가 된다.
a와 b가 10이 아닌 서로 다른 객체를 가리키도록 코드를 수정하면, 상수 객체 10은 레퍼런스 카운트가 0이 되고 메모리에서 해제된다.객체 참조에 의한 전달(파이썬) - 변경 가능 객체를 전달할 때
#코드 2-2 def func(li): li[0] = "I am your father!" #1 if __name__ == "__main__": li = [1, 2, 3, 4] func(li) print(ㅣi)
#실행 결과 ["I am your father!", 2, 3, 4]
#코드 2-3 def func(li): li = ["I am your father" , 2, 3, 4] #1 if __name__ == "__main__": li = [1, 2, 3, 4] func(li) print(li)
#실행결과 [1, 2, 3, 4]
코드 2-2를 보면 리스트의 요소가 변경되어 출력됐고, 2-3은 함수 안에서 리스트 li에 새로운 리스트를 통째로 할당하였지만 리스트가 변경되지 않았다.
코드 2-2와 2-3을 보면 한 가지 차이점을 발견할 수 있다.
- 코드 2-2 : 참조한 리스트에 접근해 변경을 시도
- 코드 2-3 : 아예 다른 리스트를 메모리 공간에 새로 만든 다음 이를 참조해 리스트를 변경
func 스택 프레임의 li와 main 스택 프레임의 li가 모두 같은 메모리 공간을 참조한다.
지금까지 살펴본 예제를 통해 객체 참조에 의한 전달 방식을 정리하면 다음과 같다.
- 함수 인자로 변경 불가능 객체를 전달해 값을 변경할 수 없다. 그 이유는 함수 안에서 새 객체를 만든 다음 참조하여 바꾸려 하면 함수 호출이 끝나고 스택 프레임이 사라지면서 참조도 사라지기 때문이다.
- 함수 내부에서 객체를 새롭게 할당해야 값을 변경할 수 있는 객체는 변경 불가능 객체인 상수, 문자열, 튜플뿐이다.
- 리스트나 딕셔너리 같은 변경 가능 객체도 함수 안에서 새로운 객체를 만들 경우 함수 호출이 끝나면서 객체는 사라진다.
- 그러므로 변경 가능 객체를 인자로 전달할 때도 인자로 전달된 객체에 접근하여 변경해야만 함수를 호출한 쪽의 객체를 변경할 수 있다.
- 이러한 파이썬의 인자 전달 방식을 객체 참조에 의한 전달 방식이라고 한다.
람다 함수
람다(lambda) 함수는 이름이 없는 함수다. 이름이 없기 때문에 다음 행으로 넘어가면 다시 사용할 수 없다. 자주 사용할 함수가 아니라면 필요할 때 람다 함수로 만들어 사용하면 된다.
>>> li = [i for i in range(1, 11)] >>> li [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] >>> li.sort(key = lambda x: x % 2 == 0) #1 >>> li [1, 3, 5, 7, 9, 2, 4, 6, 8, 10]
li는 1부터 10까지의 정수가 순차적으로 나열된 리스트다. 이 리스트 안에서 2의 배수와 2의 배수가 아닌 수로 나누고 싶다면 #1처럼 정렬하는데 필요한 key 인자에 람다 함수를 전달하면 된다. 2의 배수가 아니라면 False이므로 0이 되고, 2의 배수면 True이므로 1이 된다. 그리고 오름차순으로 정렬되면 2의 배수가 뒤에 배치된다.
정렬 기준으로 사용하기 위해 함수를 따로 정의하는 것은 번거로운 작업이다. 이때 람다 함수를 사용하면 매우 편리하게 정렬 기준을 제공할 수 있다. 람다 함수를 변수로 받으면 함수 정의를 한 것처럼 사용할 수 있다.
>>> f = lambda x: x ** 2 >>> f(2) 4 >>> f(5) 25
람다 함수는 반환하는 return 문이 없다. 또한 함수의 몸체에는 반드시 식이 들어가야 한다.
'python' 카테고리의 다른 글
파이썬 - 인수(argument) (0) 2021.03.16 클래스 - 메서드 오버라이딩과 다형성 (0) 2021.03.11 클래스 - 클래스 관계 (0) 2021.03.11 객체 지향 프로그래밍 (0) 2021.03.05 파이썬 - 절차지향 프로그래밍 (0) 2021.03.04