Logo

파이썬의 reversed() 함수로 거꾸로 루프 돌리기 (vs. slicing 연산자 & reverse() 함수)

이번 포스팅에서는 파이썬에서 reversed() 함수를 이용해서 거꾸로 루프를 돌리는 방법에 대해서 알아보려고합니다. 뿐만 아니라 reversed() 함수와 비슷해보이지만 오묘하게 틀린 리스트의 slicing 연산자와 reverse() 함수에 대해서 간단히 살펴보도록 하겠습니다.

거꾸로 루프 돌리기

다음과 같이 5개의 알파멧 문자를 담고 있는 리스트를 어떻게 루프 돌면서 각 문자를 출력할 수 있을까요?

letters = ['A', 'B', 'C', 'D', 'E']

아마도 다음과 같이 간단한 for 문으로 어렵지 않게 각 문자에 순서대로 접근할 수 있을 것 입니다.

for letter in letters:
    print(letter)
결과
A
B
C
D
E

그럼 역방향으로 각 문자에 접근하려면 어떻게 해야할까요? 이럴 때는 다음과 같이 파이썬에 내장된 reversed() 함수를 사용합니다.

for letter in reversed(letters):
    print(letter)
결과
E
D
C
B
A

혹시 다음과 같이 range() 함수를 이용해서 인덱스를 역순으로 만들어내는 것을 먼저 떠올리셨나요?

for idx in range(len(letters) - 1, -1, -1):
    print(letters[idx])

특히 다른 프로그래밍 언어를 사용하시다가 파이썬으로 넘어오신 분들이 역방향으로 루프를 돌릴 때 range() 함수를 쓰시는 것을 종종 목격하게 되는데요.

보시다시피 range() 함수를 써서 역방향으로 루프를 돌리면 코드가 읽기 어려워져 파이썬 커뮤니티에서는 그리 좋은 코딩 스타일로 보지 않습니다.

파이썬의 또 다른 내장 함수인 range() 함수에 대해서는 별도의 포스팅에서 자세히 다루었니 참고 바랍니다.

다음과 같이 리스트의 슬라이싱(slicing) 연산자([::-1])를 쓰는 경우도 어렵지 않게 볼 수 있는데요.

for letter in letters[::-1]:
    print(letter)

이 방법은 가독성 측면에서는 나쁘지는 않지만 메모리 사용량 측면에서 크게 추천하고 싶지 않은 방법이에요. 같은 원소를 역방향으로 담고 있는 동일한 크기의 리스트를 새로 만들기 때문에 불필요하게 추가 메모리를 소모하게 되기 때문입니다.

reversed() 내장 함수

역방향으로 루프 돌릴 때 다른 방법 대비 reversed() 내장 함수를 쓰는 게 왜 좋은지 이해하려면 reversed() 내장 함수의 특징을 알아야하는데요.

기본적으로 reversed() 함수는 인자로 리스트(list) 뿐만 아니라 터플(tuple), 문자열(string)과 같은 여러 원소로 이뤄진 자료구조를 받을 수 있습니다.

그리고 주어진 자료구조에 담긴 원소들을 역순으로 순회할 수 있도록 반복자(iterator)를 결과값으로 반환하는데요. 바로 여기서 특이한 부분은 반환 타입(return type)으로 인자로 넘어온 자료구조와 동일한 타입을 사용하지 않고, 대신에 반복자 타입을 사용한다는 점입니다.

이를 직접 확인해보려면 reversed() 함수의 호출 결과를 그대로 출력해보면 됩니다.

reversed_letters = reversed(letters)
print(reversed_letters)
결과
<reversed object at 0x1050ca1d0>

반환된 반복자를 상대로 next() 함수를 호출해보면 reversed() 함수에 인자로 넘어갔던 리스트 내의 문자가 역순으로 하나씩 접근이 되는 것을 볼 수 있습니다.

next(reversed_letters)
결과
'E'
next(reversed_letters)
결과
'D'
next(reversed_letters)
결과
'C'

나머지 문자들은 for 문을 이용해서 마저 출력해보겠습니다.

for letter in reversed_letters:
    print(letter)
결과
B
A

이렇게 반복자를 반환하면 for 문으로 루프를 돌릴 때 메모리 사용량 측면에서 큰 이점이 있는데요. 반복자는 미리 메모리에 모든 원소를 올려놓지 않고 필요할 때 마다 원소를 하나씩 제공해줍니다.

당연한 얘기일 수도 있지만 세트(set)와 같이 순서 개념이 없어서 인덱스로 접근할 수 없는 자료구조를 상대로 reversed() 함수를 사용할 수 없으니 참고바랍니다.

reversed({'A', 'B', 'C'})
결과
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'set' object is not reversible

리스트의 slicing 연산자

만약에 루프를 돌지 않고 같은 원소들을 단지 역방향으로 담고 있는 새로운 리스트를 얻고 싶을 때는 어떻게 해야할까요? 그렇때는 list() 함수를 이용하여 reversed() 함수가 반환해준 반복자를 한번에 소비(consume)해주면 됩니다.

list(reversed(letters))
결과
['E', 'D', 'C', 'B', 'A']

하지만 거꾸로 돌아간 리스트를 만들기 위해서 두 개의 함수를 연속으로 호출하는 것이 좀 번거로워보이죠? 사실 리스트에서 기본적으로 제공하는 슬라이싱(slicing) 연산자([::-1])를 이용하면 훨씬 간편하게 역전된 리스트를 만들 수 있습니다.

letters[::-1]

참고로 리스트 표현식(list comprehension)을 활용해서도 같은 효과를 낼 수는 있으나 이전 방법이 더 낫죠?

[letters[i] for i in range(len(letters) - 1, -1, -1)]

리스트의 reverse() 함수

파이썬의 리스트(list)는 reversed() 내장 함수와 이름이 엇비슷한 reverse()라는 함수를 제공하는데요. 많은 분들이 이 두 함수가 비슷할 거라고 예상하시는데 사실 이 두 함수는 본질적으로 큰 차이가 있기 때문에 짚고 넘어가려고 합니다.

리스트의 reverse() 함수는 새로운 리스트를 생성하지 않고 기존 리스트 내의 원소들을 제자리에서(in place) 역방향으로 재배치해주는데요. 이 함수를 호출하면 reversed() 내장 함수처럼 반복자를 반환하는 것이 아니라 단순히 리스트 내에 원소들이 제자리에서 역방향으로 재배치가 됩니다.

print(letters)
letters.reverse()
print(letters)
결과
['A', 'B', 'C', 'D', 'E']
['E', 'D', 'C', 'B', 'A']

같은 코드를 한 번 더 실행해보면 리스트가 두 번 거꾸로 뒤짚어져 최초의 모습으로 돌아오는 것을 볼 수 있습니다.

print(letters)
letters.reverse()
print(letters)
결과
['E', 'D', 'C', 'B', 'A']
['A', 'B', 'C', 'D', 'E']

여기서 조심할 부분은 어떤 리스트를 상대로 reverse() 함수를 호출하면 실제로 해당 리스트에 변경을 가해진다는 것입니다. 즉, 리스트 내에 원소들 간에 자리가 서로 바뀌어서 리스트의 본래 구조를 잃어 버리게 됩니다. 따라서 리스트의 본래 구조를 보존해야하는 경우에는 reverse() 함수를 사용하면 절대 안 되겠습니다.

위와 같이 리스트 객체의 reverse() 함수가 아무것도 반환하지 않는 것도 같은 맥락으로 이해할 수 있는데요. 역순으로 재배치된 새로운 리스트를 만들어내는 것이 아니기 때문에 None을 반환할 수 밖에 없는 것이지요.

print(letters.reverse())
결과
None

반면에 reversed() 내장 함수는 인자로 넘어온 리스트를 전혀 건드리지 않으며 대신 리스트의 원소에 역방향으로 접근할 수 있는 반복자를 반환합니다. 이 두 함수의 이러한 본질적인 차이를 이해하지 않고 무분별하게 사용하시면 낭패를 볼 수 있으니 주의바라겠습니다.

전체 코드

본 포스팅에서 제가 작성한 코드는 아래에서 직접 확인하고 실행해보실 수 있습니다.

https://dales.link/o1h

마치면서

파이썬에서 reversed() 내장 함수는 역방향으로 루프 돌리기 위해서 고안되었다고 말해도 과언이 아닐 정도인데요. reversed() 내장 함수를 사용해서 루프를 돌면 코드가 읽기 쉬울 뿐만 아니라 메모리도 적게 사용하기 때문에 성능도 좋아집니다.

반면에 같은 원소들을 역방향으로 담고 있는 새로운 리스트 생성할 때는 슬라이싱(slicing) 연산자를 사용하는 편이 좋으며, 리스트 내의 원소들을 제자리에서 역방향으로 재배치할 때는 리스트가 제공하는 reverse()라는 함수를 사용하면 됩니다.