본문 바로가기

함수·리스트·복사와 작은 검사

계산을 함수로 묶어 여러 입력에 다시 쓰고, 0부터 세는 인덱스와 원본·복사의 차이를 구분한 뒤 assert로 결과를 검사합니다.

알고리즘이 맞는지 확인하려면 같은 계산을 여러 입력에 돌려 봐야 합니다. 평범한 입력, 비어 있는 입력, 값이 겹치는 입력 같은 것들입니다. 그때마다 코드를 복사해 고치면 실수가 생기기 쉬우므로, 계산을 함수로 묶어 입력만 바꿔 가며 부르는 편이 낫습니다. 이 글에서는 함수에 값을 넘기고 결과를 돌려받는 방법, 값 여러 개를 담는 리스트, 그리고 리스트를 넘겼을 때 원본이 바뀌는지 확인하는 방법을 다룹니다. 마지막에는 assert로 결과를 자동으로 검사합니다.

함수로 계산을 묶기

함수(function)는 def 이름(인자):로 정의합니다. 인자(argument)는 함수를 부를 때 건네는 입력이고, 함수 본문의 return은 계산한 결과를 부른 곳으로 돌려줍니다.

def square(x):
    return x * x

print(square(3))
print(square(5) + 1)
9
26

square(5) + 1처럼 함수를 부른 자리에는 return이 돌려준 값이 들어가므로, 그 결과를 다른 계산에 바로 이어 쓸 수 있습니다. 여기서 헷갈리기 쉬운 점은 print와 return의 차이입니다. print는 값을 화면에 보여 줄 뿐 부른 곳에 아무것도 돌려주지 않습니다. return 없이 끝나는 함수는 "값 없음"을 뜻하는 None을 돌려줍니다.

def show_square(x):
    print(x * x)

result = show_square(3)
print(result)
9
None

화면에는 9가 보이지만 result에 들어간 값은 None입니다. 화면 출력만 보고 함수가 올바른 결과를 돌려준다고 믿으면 이 차이를 놓치기 쉽습니다. 이 교재에서 결과를 검사하는 함수는 모두 return으로 값을 돌려줍니다.

리스트와 0부터 세는 인덱스

리스트(list)는 순서가 있는 값의 모음입니다. 대괄호 안에 값을 쉼표로 나열해 만들고, 길이는 len으로 구합니다. 원소 하나는 위치를 나타내는 번호인 인덱스(index)로 꺼내는데, Python의 인덱스는 0부터 셉니다.

values = [4, 1, 3]
print(len(values))
print(values[0], values[1], values[2])
print(values[len(values) - 1], values[-1])
3
4 1 3
3 3

원소가 세 개이므로 인덱스는 0, 1, 2이고, 마지막 원소의 인덱스는 길이에서 1을 뺀 2입니다. 음수 인덱스는 뒤에서부터 세므로 values[-1]도 마지막 원소를 가리킵니다. 길이와 같은 인덱스인 values[3]은 존재하지 않는 위치라서 오류가 납니다.

values = [4, 1, 3]
print(values[3])
Traceback (most recent call last):
  File "C:\algo\lesson.py", line 2, in <module>
    print(values[3])
          ~~~~~~^^^
IndexError: list index out of range

반복하다가 인덱스를 하나 더 세는 실수는 알고리즘에서 아주 흔합니다. 인덱스가 0부터 시작하므로 마지막 인덱스는 길이보다 1 작다는 점을 기억해 두면, 이 오류가 났을 때 어디를 볼지 바로 알 수 있습니다.

같은 리스트를 가리키는 두 이름

리스트를 다른 변수에 대입하면 복사가 일어날 것 같지만 그렇지 않습니다. alias = values는 같은 리스트 하나에 이름을 하나 더 붙일 뿐입니다. 새 리스트가 필요하면 list(values)나 슬라이스 values[:]로 명시적으로 복사해야 합니다.

values = [4, 1, 3]
alias = values
copy = list(values)

alias[0] = 99
print(values)
print(alias)
print(copy)
print(alias is values, copy is values)
[99, 1, 3]
[99, 1, 3]
[4, 1, 3]
True False

alias[0]을 바꿨는데 values까지 바뀌었습니다. 두 이름이 같은 리스트를 가리키기 때문입니다. 반면 copy는 처음에 내용만 같은 새 리스트로 만들어졌으므로 영향을 받지 않습니다. is는 두 이름이 정확히 같은 객체를 가리키는지 묻는 연산자라서, alias is values는 참이고 copy is values는 거짓입니다. 아래 도표는 이 관계를 그림으로 보여 줍니다.

자료구조·알고리즘 · 상태와 관계

같은 이름값과 실제 복사

values와 alias는 하나의 리스트를 가리키지만 copy는 새 리스트입니다.

같은 이름값과 실제 복사두변수values와alias가위쪽같은리스트4,1,3을가리키고copy는아래쪽별도리스트4,1,3을가리킨다.내용은같아도객체는다르다.valuesalias[4,1,3]copy[4,1,3]위: 같은 객체 · 아래: 새 객체

화살표는 변수 이름이 가리키는 객체입니다. 강조한 새 리스트를 바꾸어도 위 리스트의 숫자 원소는 바뀌지 않습니다. 중첩 객체의 깊은 복사를 뜻하지는 않습니다.

이 차이는 함수에 리스트를 넘길 때 특히 중요합니다. 함수 안에서 받은 리스트를 고치면, 그것은 부른 쪽이 가진 바로 그 리스트를 고치는 것입니다. 그래서 원본을 보존해야 하는 함수는 새 리스트를 만들어 결과를 담아 돌려줍니다.

더 깊이: 복사는 한 겹만 새로 만든다

list(values)나 values[:]는 바깥 리스트만 새로 만들고, 그 안에 든 원소는 원본과 함께 씁니다. 숫자처럼 바꿀 수 없는 값만 들어 있다면 문제가 없지만, 리스트 안에 리스트가 들어 있으면 차이가 드러납니다.

grid = [[1, 2], [3]]
shallow = list(grid)
shallow[0].append(9)
shallow.append([7])
print(grid)
print(shallow)
print(grid[0] is shallow[0])
[[1, 2, 9], [3]]
[[1, 2, 9], [3], [7]]
True

shallow.append([7])은 바깥 리스트에 원소를 더하는 일이라 grid에 영향을 주지 않습니다. 하지만 shallow[0]은 grid[0]과 같은 안쪽 리스트이므로, 거기에 9를 더하면 grid에서도 보입니다. 이런 복사를 얕은 복사(shallow copy)라고 합니다. 안쪽까지 모두 새로 만드는 깊은 복사(deep copy)는 표준 라이브러리의 copy.deepcopy가 맡는데, 이 교재의 예제는 대부분 숫자 리스트라서 얕은 복사로 충분합니다.

반복 결과를 한 줄로 모으기

뒤에서 만날 코드에는 for 반복으로 새 리스트를 만드는 일을 한 식으로 줄여 쓴 리스트 컴프리헨션(list comprehension)이 자주 나옵니다. 대괄호 안에 "만들 값"과 "꺼낼 곳"을 적고, 끝에 if 조건을 붙이면 조건이 참인 원소만 담습니다.

values = [4, 1, 3]
print([value * 2 for value in values])
print([value for value in values if value % 2 == 1])
[8, 2, 6]
[1, 3]

첫 식은 각 원소를 두 배로 만든 새 리스트이고, 둘째 식은 홀수인 원소만 골라 담은 새 리스트입니다. 두 경우 모두 원래의 values는 바뀌지 않습니다.

두 변수의 값을 맞바꾸는 묶음 대입도 자주 씁니다. a, b = b, a는 오른쪽의 b, a를 지금의 값으로 먼저 모두 계산한 뒤 왼쪽 두 이름에 한꺼번에 대입합니다. 그래서 임시 변수 없이도 값이 섞이지 않고 안전하게 바뀝니다.

a, b = 1, 2
a, b = b, a
print(a, b)
2 1

함수의 결과를 assert로 검사하기

이제 리스트의 모든 원소를 두 배로 만든 새 리스트를 돌려주는 함수를 작성하고, 결과가 기대와 같은지 자동으로 검사해 보겠습니다. 먼저 함수를 부르기 전후로 상태가 어떻게 바뀌는지 예측합니다.

시점valuesresult설명
호출 직후[4, 1, 3][]빈 결과 리스트를 새로 만든다
첫 원소 처리[4, 1, 3][8]4의 두 배를 결과에 넣는다
마지막 원소 처리[4, 1, 3][8, 2, 6]결과를 돌려준다
빈 입력[][]반복을 한 번도 돌지 않고 빈 결과를 돌려준다

원본인 values는 표의 어느 줄에서도 바뀌지 않습니다. 함수가 받은 리스트를 고치지 않고 새 리스트에만 값을 넣기 때문입니다.

def double_all(values):
    result = []
    for value in values:
        result.append(value * 2)
    return result

values = [4, 1, 3]
doubled = double_all(values)
print(values)
print(doubled)
assert doubled == [8, 2, 6]
assert values == [4, 1, 3]
assert double_all([]) == []
print("검사 통과")
[4, 1, 3]
[8, 2, 6]
검사 통과

append는 리스트 끝에 원소를 하나 더합니다. assert 조건은 조건이 참이면 아무 일도 하지 않고 다음 줄로 넘어가며, 거짓이면 그 자리에서 프로그램을 멈춥니다. 세 검사가 모두 참이었으므로 마지막 줄의 검사 통과까지 출력됐습니다. 각 검사가 확인하는 것도 서로 다릅니다. 첫째는 결과가 맞는지, 둘째는 원본을 건드리지 않았는지, 셋째는 빈 입력도 처리하는지 봅니다.

일부러 틀린 함수로 검사가 실패하는 모습도 확인해 둡니다. 곱셈 * 2를 덧셈 + 2로 잘못 적으면 다음처럼 멈춥니다.

def double_all(values):
    result = []
    for value in values:
        result.append(value + 2)
    return result

assert double_all([4, 1, 3]) == [8, 2, 6]
print("검사 통과")
Traceback (most recent call last):
  File "C:\algo\lesson.py", line 7, in <module>
    assert double_all([4, 1, 3]) == [8, 2, 6]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

AssertionError는 실패한 검사가 어느 줄인지만 알려 줍니다. 실제로 어떤 값이 나왔는지는 print(double_all([4, 1, 3]))로 따로 출력해 봐야 합니다. 이 결과는 [6, 3, 5]라서 기대와 다릅니다. assert 조건, "메시지"처럼 쉼표 뒤에 문장을 붙이면 실패할 때 그 문장이 AssertionError 뒤에 함께 표시됩니다.

assert는 프로그램을 만드는 사람이 자기 코드의 실수를 잡으려고 쓰는 도구입니다. Python을 python -O처럼 최적화 옵션과 함께 실행하면 assert 문을 아예 건너뛰기 때문에, 사용자가 넣은 값이 올바른지 확인하는 용도로는 쓰지 않습니다. 그런 검사는 뒤에서 ValueError 같은 예외를 직접 일으키는 방법으로 다룹니다.

왜 맞고 얼마나 드는가

double_all이 맞다는 것은 앞 글의 불변조건으로 설명할 수 있습니다. 반복이 원소 k개를 처리한 시점마다 result에는 앞의 원소 k개를 각각 두 배로 만든 값이 같은 순서로 들어 있습니다. 처음에는 k가 0이고 result도 비어 있으므로 참이고, 원소를 하나 처리할 때마다 그 두 배를 끝에 붙이므로 다시 참이 됩니다. 반복은 리스트 길이만큼만 돌고 끝나므로, 끝난 뒤의 result는 모든 원소의 두 배를 순서대로 담고 있습니다. 빈 리스트라면 반복을 한 번도 돌지 않고 빈 result를 그대로 돌려줍니다.

입력이 n개이면 곱셈을 n번 하고, 결과 리스트에 원소 n개를 저장합니다. 그래서 실행 시간과 추가로 쓰는 메모리가 모두 n에 비례해 늘어나며, 2장에서 배울 표기로는 둘 다 O(n)입니다. 원본을 보존하려고 새 리스트를 만드는 선택에도 이렇게 메모리라는 비용이 따른다는 점을 기억해 두세요.

검사를 통과했다는 것의 의미

assert가 모두 통과했다고 해서 함수가 모든 입력에서 맞다는 뜻은 아닙니다. 검사한 입력에서 맞다는 것만 알 수 있습니다. 그래서 어떤 입력을 고르는지가 중요합니다. 평범한 입력 하나만 보지 말고, 비어 있는 입력, 원소가 하나뿐인 입력, 같은 값이 여러 번 나오는 입력, 0이나 음수처럼 계산의 성질이 바뀔 수 있는 값을 함께 넣어 보세요. 그리고 각 입력을 왜 골랐는지 설명할 수 있어야 합니다. 이 교재에서 알고리즘을 검사할 때도 이 원칙을 따릅니다.

정리

  • 함수는 def로 정의하고 return으로 결과를 돌려줍니다. print는 화면에 보여 줄 뿐이고, return이 없는 함수는 None을 돌려줍니다.
  • 리스트의 인덱스는 0부터 세며, 마지막 인덱스는 길이보다 1 작습니다. 범위를 벗어나면 IndexError가 납니다.
  • alias = values는 같은 리스트에 이름을 하나 더 붙이는 일입니다. 새 리스트가 필요하면 list(values)나 values[:]로 복사하며, 이 복사는 바깥 한 겹만 새로 만듭니다.
  • 리스트 컴프리헨션은 반복으로 새 리스트를 만드는 일을 한 식으로 줄여 씁니다. a, b = b, a는 오른쪽을 먼저 계산한 뒤 함께 대입합니다.
  • assert는 조건이 거짓이면 프로그램을 멈춥니다. 결과, 원본 보존, 빈 입력처럼 확인할 성질마다 검사를 하나씩 두고, 입력을 고른 이유를 설명할 수 있어야 합니다.

연습 문제

1. 결과를 바꾸면 원본도 바뀔까

doubled = double_all(values) 다음에 doubled[0] = 99를 실행하면 values[0]도 바뀔까요? 실행하기 전에 함수가 새 리스트를 만드는지부터 살펴보세요.

해설 보기

바뀌지 않습니다. double_all은 함수 안에서 result = []로 새 리스트를 만들고 거기에 값을 담아 돌려주므로, doubled와 values는 서로 다른 리스트입니다.

def double_all(values):
    result = []
    for value in values:
        result.append(value * 2)
    return result

values = [4, 1, 3]
doubled = double_all(values)
doubled[0] = 99
print(values)
print(doubled)
[4, 1, 3]
[99, 2, 6]

만약 함수가 받은 리스트를 직접 고쳐서 그것을 돌려줬다면 두 이름은 같은 리스트를 가리켰을 것이고, values[0]도 99가 됐을 것입니다.

2. return 대신 print를 쓰면

double_all의 return result를 print(result)로 바꾸면 doubled에는 무엇이 들어갈까요? 그리고 assert doubled == [8, 2, 6]은 어떻게 될까요?

해설 보기

목록 [8, 2, 6]은 화면에 출력되지만, 함수가 아무것도 돌려주지 않으므로 doubled에는 None이 들어갑니다. 그래서 None == [8, 2, 6]은 거짓이 되고 assert가 실패합니다.

def double_all(values):
    result = []
    for value in values:
        result.append(value * 2)
    print(result)

doubled = double_all([4, 1, 3])
print(doubled)
[8, 2, 6]
None

화면에 올바른 목록이 보였는데도 검사가 실패한다면, 먼저 함수가 값을 return하고 있는지 확인하세요.

3. 검사할 입력 고르기

리스트에서 가장 큰 값을 돌려주는 함수를 만든다고 해 봅시다. 다음 입력 가운데 검사에 꼭 넣어야 할 것을 고르고, 각각 무엇을 확인하려는 것인지 말해 보세요. [3, 7, 5], [7], [2, 9, 9], [-4, -1, -8], []

해설 보기

모두 의미가 있습니다. [3, 7, 5]는 가장 큰 값이 가운데에 있는 평범한 경우입니다. [7]은 원소가 하나뿐일 때 그 원소를 돌려주는지 봅니다. [2, 9, 9]는 가장 큰 값이 여러 번 나와도 올바르게 처리하는지 봅니다.

[-4, -1, -8]은 특히 중요합니다. 가장 큰 값을 찾을 때 비교를 0에서 시작하는 코드는 모든 값이 음수이면 0을 잘못 돌려줍니다. 마지막으로 []은 "가장 큰 값"이 정의되지 않는 입력이므로, 함수가 이때 무엇을 해야 하는지 미리 정해 두어야 합니다. 오류를 내야 할 수도 있고, 약속한 특별한 값을 돌려줄 수도 있습니다. 입력을 고를 때는 이렇게 "이 입력이 없으면 어떤 실수를 놓치는가"를 함께 생각합니다.

다음 글 문제와 입출력에서는 함수를 작성하기 전에 그 함수가 무엇을 받고 무엇을 돌려줘야 하는지 먼저 정하는 방법을 다룹니다. 리스트의 여러 기능은 Python 공식 문서의 자료구조에 정리되어 있습니다.