Python에서 언더스코어는 단순한 식별자 (identifier) 이상의 의미를 지닌다. 언더스코어를 적절히 활용하면 클래스의 접근 제어 등 OOP 관점에서 더 우수한 코드, 혹은 더 예쁜 코드를 작성할 수 있다.
본 포스트에서는 Python에서 언더스코어가 어떻게 활용되는지 정리하고자 한다. 잘 정리된 블로그가 이미 많지만, 헷갈릴 때마다 찾아보는 것이 번거로워 필자가 다시 한번 정리하고자 한다.
간단한 역할부터 하나씩 살펴보겠다.
Python에서 언더스코어의 역할이라고 하기에는 애매하지만, 나름 중요한 역할이다. 스네이크 케이스란 프로그래밍의 naming convention 중 하나인데, 식별자 하나에서 단어 구분을 언더스코어로 하는 것을 의미한다. Python에서의 예시는 아래 코드를 참조하자.
my_score = 10
def string_sum(x: int, y: int) -> str:
return str(x) + str(y)
위와 같이 하나의 식별자가 여러 단어로 구성될 때 언더스코어로 단어를 구별한다. 이름에서 볼 수 있듯 스네이크 케이스 식별자는 뱀같이 생겼다.
파이썬에서는 주로 변수와 함수의 이름은 스네이크 케이스로, 클래스 이름은 카멜 케이스로 작성한다.
class MyClass:
...
이런 식으로 단어의 구분을 대문자 알파벳으로 하는 것을 카멜 케이스라고 부른다. 마치 클래스의 이름이 쌍봉낙타같지 않은가?
C 계열의 언어에서는 카멜 케이스와 파스칼 케이스를 주로 사용하곤 하는데, 다양한 네이밍 컨벤션에 대해 더 알고자 한다면 freeCodeCamp의 포스트를 참고하자.
터미널에서 Python 인터프리터를 이용할 때, 언더스코어는 가장 최근 반환값(return value)을 의미한다. 아래 코드를 예제로 살펴보자.
>>> a = 10
>>> a + 3
13
>>> _
13
>>> type(_)
<class 'int'>
두 번째 명령 a + 3은 13이라는 결과를 반환한다. 이때 _는 13이라는 값을 가리킨다. 실제로 _ 명령은 13을 출력하며 type(_)는 int 타입이라는 것을 보여준다.
❗️ 출력값이 아닌 반환값이라는 것을 기억하자.
>>> a = 10
>>> print(a)
10
>>> _
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name '_' is not defined
print(a)는 변수 a의 값 10을 출력할 뿐 아무것도 반환하지 않는다. 따라서 _에 접근하면 아직 정의되지 않았다는 오류가 발생하는 것을 볼 수 있다.
Python은 하나의 함수가 Tuple의 형태로 여러 개의 값을 한번에 반환할 수 있다는 장점을 지닌다. 이에 따라 아래와 같은 unpacking이 가능하다.
def three_tuple(x: List[Any]) -> Tuple[Any]:
assert len(x) == 3
return x[0], x[1], x[2]
a, b, c = three_tuple([1, 2, 3])
정말 pythonic 하지 않은 코드이지만, 하나의 예제로 보도록 하자. 위 코드의 three_tuple() 함수는 3개의 원소를 지닌 리스트를 받아 동일한 순서로 동일한 원소를 지닌 튜플을 반환한다.
만약 우리가 함수의 두 번째 반환값을 무시하고자 한다면 어떻게 해야 할까? 이때 언더스코어를 이용한다.
a, _, b = three_tuple([1, 2, 3])
위와 같은 방식을 통해 첫 번째와 세 번째 반환값만 저장할 수 있다. 만약에 첫 번째 반환값만 저장하고자 한다면 아래와 같이 언더스코어를 두 번 사용할 수도 있다.
a, _, _ = three_tuple([1, 2, 3])
❗️ 위와 같은 방식으로 언더스코어를 이용하면 언더스코어는 실제로 값을 가진다. 즉,
print(_)를 실행하면 3이 출력된다. 2가 아닌 3이_에 저장되는 이유는 Python 또한 여타 다른 언어처럼 쉼표(comma) 연산자가 사용되면 앞에서부터 순서대로 값을 evaluate하기 때문이다.
Python의 표준을 규정하는 PEP8에 따르면 언더스코어는 더 특별한 의미로 사용될 수 있다. 아래에서 설명하는 몇 가지 방법을 활용하면 더 pythonic한 코드를 작성할 수 있겠다. PEP8에 대해 더 궁금하다면 공식 문서를 참고하자.
아래에서 name은 설명을 위한 임의의 식별자이다. name 앞뒤에 몇 개의 언더스코어가 붙는지에 집중하고 살펴보자.
아마 많은 독자들이 이러한 형식의 식별자를 이미 경험했을 것이다. Python에는 언어 차원에서 이미 몇 가지 특별한 식별자를 지정해뒀다. __init__(), __getitem__()과 같이 두 개의 언더스코어가 식별자 앞뒤에 붙은 경우에는 언어 차원에서 식별자를 미리 예약했다는 것을 의미한다.
이러한 식별자를 만들거나 완전히 다른 의미를 지니도록 하는 것이 가능하지만, PEP8은 이를 권장하지 않는다. 그냥 언어가 하라는대로 하자. Python은 언어가 하라는대로 했을 때 가장 아름답다.
Python 키워드(keyword)와의 충돌을 막기 위해 사용된다. 아래의 코드를 참고하자.
def func(x: int, class=None) -> None:
...
위와 같은 함수는 문법적인 오류를 범하고 있다. 언어 차원에서 예약된 키워드인 class를 매개변수의 이름으로 사용하고 있기 때문이다. 이럴 때 식별자 끝에 언더스코어를 붙여서 충돌을 막을 수 있다.
def func(x: int, class_=None) -> None:
...
충돌을 막는 것보다 겉멋을 부리는 것이 더 큰 목적이긴 하다. PEP8은 겉멋을 위해 무분별하게 식별자 끝에 언더스코어를 붙이는 것을 권장하지 않는다. 그래도 필자는 겉멋을 위해 애용한다.
모듈에서 내부 사용을 위한 오브젝트라는 것을 표시하기 위해 사용된다. 아래 코드를 참조하자.
# calc.py
def square(x: float) -> float:
return x ** 2
def _dont_import(x: float) -> float:
...
# main.py
from calc import *
main.py의 from calc import *는 square() 함수는 임포트 하지만, _dont_import() 함수는 임포트하지 않는다.
이렇듯 모듈의 모든 오브젝트를 임포트할 때 자동으로 임포트되는 것을 막기 위해 언더스코어는 사용될 수 있다. 사실 이러한 사용뿐만 아니라 아래와 같이 클래스에서 외부의 접근을 완곡하게 제한하기 위해서도 사용된다.
class MyClass:
def call_me():
...
def _inside():
...
클래스 메소드의 이름 앞에 언더스코어 하나를 붙임으로써 이 함수에는 되도록이면 외부에서 접근하지 말라는 의미를 지니게 된다. 다만, 실제로는 자유롭게 접근할 수 있다는 것을 기억하자.
맹글링(mangling)이라고 불리는 방식으로, 클래스의 속성(변수, 메소드) 식별자 앞에 두 개의 언더스코어를 붙임으로써 다른 언어에서 private 접근제한자를 사용한 것과 매우 유사한 효과를 얻을 수 있다. 아래 코드를 예시로 살펴보자.
class MyClass:
def __init__(self) -> None:
self.var = 30
self.__priv = 'hello'
def __priv_fn(self) -> None:
print(self.__priv)
obj = MyClass()
print(obj.var) # 30
print(obj.__priv) # AttributeError
obj.__priv_fn() # AttributeError
위 코드에서 볼 수 있듯 식별자 앞에 두 개의 언더스코어가 붙은 경우에는 클래스 외부에서 식별자의 이름으로 접근할 수 없다. 그러나, 클래스 내부 메소드에서는 식별자 이름으로 접근할 수 있다.
이렇듯, 마치 식별자에 private 접근제한자를 적용한 것과 같은 효과를 얻을 수 있다. 다만, 완전히 private인 것은 아니다. 아래 코드를 통해 클래스의 속성에 접근할 수 있다.
print(obj._MyClass__priv)
obj._MyClass__priv_fn()
즉, 속성 앞에 언더스코어 + 클래스 이름을 붙임으로써 접근할 수 있다. 필자가 private과 유사한 효과를 얻을 수 있다고 언급한 이유이다.