Python

Python Callable

둔진 2022. 7. 11. 15:09

callable은 무엇이지?

Python에서 callable은 함수를 호출하듯이 호출할 수 있는 객체를 의미합니다. 프로그래밍 언어 용어에 대한 설명답게 뭔가 미묘하면서 깔끔하지 않은 느낌이 드는 문장이죠? "함수를 호출하듯이 호출한다"는 부분이 눈에 걸리지 않을까 합니다. 일반적인 프로그래밍 언어에서는 호출할 수 있는 것은 함수(또는 메소드) 뿐이기 때문입니다. 변수 a를 호출하세요. 클래스 Cat을 호출하세요. 이상하죠? 그럼 Python에는 함수 외에도 호출할 수 있는 무언가가 있다는 뜻일까요? 짧은 답은 "그렇다"입니다. 긴 답은 아래에서 차근차근 이야기해보겠습니다.

callable() 함수

먼저 Python이 built-in으로 제공하는 callable() 함수를 살펴보겠습니다. callable()은 전달받은 인자가 callable 객체라면 True를 반환하고 아니면 False를 반환합니다.

def f():
  print('hello')

print(callable(f))
True

print(callable(f()))이 아니고 print(callable(f))입니다. f()를 호출하는 것이 아니고, 함수 f 자체를 인자로 전달하기 때문입니다.

  • f: type이 function인 instance입니다.
  • f(): 함수 f를 호출합니다.

Python에는 일반적으로 코딩을 할 때는 도통 잘 쓸 것 같지 않은 built-in 함수들이 있습니다. callable()도 그 중 하나이고요. 왜 어떤 객체가 callable인지 아닌지 궁금할까요? 어차피 내가 만든 객체라면 애초에 callable인지 아닌지 알텐데요.

숫자 1은 상수이기 때문에 호출할 수 없습니다. 즉 1은 callable이 아닙니다. 그래서 아래와 같이 하면 에러가 발생합니다.

1()
TypeError: 'int' object is not callable

그래서 아래와 같이 하면 안전한 코드를 짤 수 있습니다.

if callable(1):
  1()

완벽하죠. 하지만 굳이 누가 이런 코드를 짤까요.

문제는 Python에서는 함수도 객체이고 따라서 다른 함수(정확히는 callable)의 인자로 전달할 수 있다는 점입니다. 아래 코드를 살펴보겠습니다.

import datetime
import time

def measure_time(f):
  start_time = datetime.datetime.now()
  f()
  end_time = datetime.datetime.now()

  print(end_time - start_time)

def very_complex_task():
  print('starting')
  time.sleep(3)
  print('ended')

measure_time(very_complex_task)

measure_time() 함수는 인자로 전달된 함수의 수행 시간을 측정합니다. () 없이 very_complex_task를 인자로 전달하기 때문에 very_complex_task()의 실행결과가 아니라 함수 객체가 전달됩니다. 함수도 객체이기 때문에 아래처럼 list에 담을 수 있습니다.

import datetime
import time

def measure_time(f):
  start_time = datetime.datetime.now()
  f()
  end_time = datetime.datetime.now()

  print(end_time - start_time)

def very_complex_task():
  print('starting')
  time.sleep(3)
  print('ended')

def a_bit_complex_task():
  print('starting')
  time.sleep(1)
  print('ended')

functions = [very_complex_task, a_bit_complex_task]

for f in functions:
  measure_time(f)
starting
ended
0:00:03.005284
starting
ended
0:00:01.001609

만약에 어떤 이유로 functions list에 None이 하나 들어갔다면 어떻게 될까요?

import datetime
import time

def measure_time(f):
  start_time = datetime.datetime.now()
  f()
  end_time = datetime.datetime.now()

  print(end_time - start_time)

def very_complex_task():
  print('starting')
  time.sleep(3)
  print('ended')

def a_bit_complex_task():
  print('starting')
  time.sleep(1)
  print('ended')

functions = [very_complex_task, a_bit_complex_task, None]

for f in functions:
  measure_time(f)
starting
ended
0:00:03.003704
starting
ended
0:00:01.002455
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-25-6356334032df> in <module>()
     22 
     23 for f in functions:
---> 24   measure_time(f)

<ipython-input-25-6356334032df> in measure_time(f)
      4 def measure_time(f):
      5   start_time = datetime.datetime.now()
----> 6   f()
      7   end_time = datetime.datetime.now()
      8 

TypeError: 'NoneType' object is not callable

None은 callable이 아니기 때문에 위와 같이 에러가 발생합니다. 이를 피하기 위해서 measure_time()에 아래와 같이 방어 코드를 넣을 수 있습니다.

def measure_time(f):
  start_time = datetime.datetime.now()
  if callable(f):
    f()
  end_time = datetime.datetime.now()

  print(end_time - start_time)

Duck typing

이런 문제는 Python의 duck typing 이라는 특성 때문에 생깁니다. duck typing은 Python Reflection 맛보기를 보시는 것을 추천드립니다. 이 글에서는 간단히만 살펴보겠습니다.

duck typing을 이해하려면 반대로 Java와 C++ 같은 언어를 먼저 보면 좋습니다. Java나 C++ 같은 언어는 코드를 컴파일할 때와 실행할 때 모두 변수의 타입을 엄격하게 확인합니다. measure_time()을 Java나 C++ 스타일로 써본다면 아래와 같은 코드가 될 겁니다.

void measure_time(Callable f) {
// ...
}

Callable은 Interface 같은 추상 클래스류입니다. 실제 Java나 C++에 있는 클래스는 아닙니다. Callable 타입이라면 함수처럼 호출할 수 있다는 것을 보장하기 위한 클래스라고 가정해보겠습니다. fCallable 타입이라는 것이 컴파일 때와 실행 때 모두 보장되기 때문에 Python과 달리 callable()을 써서 굳이 확인할 필요가 없습니다.

반면 Python은 타입에 대해 훨씬 관대합니다. 함수의 인자 타입을 명시하지 않기 때문에 어떤 타입이든 인자로 전달할 수 있습니다. Python은 타입을 고려해야 하는 상황이 오면 변수의 타입이 무엇인지 보다 그 변수가 할 수 있는 일이 무엇인지를 중요하게 생각합니다. 어떤 새가 실제 오리(Duck)인지 아닌지는 중요하지 않고, 꽥꽥거리고 뒤뚱거리기만하면 오리(Duck)로 쳐주겠다는 철학입니다. 타입을 엄격하게 따지지 않기 때문에 융통성은 훨씬 있지만, 사전에 타입 체크를 통해 막을 수 있는 오류를 방지하기는 어렵습니다. 이를 보안하기 위해서 Python도 최근에 Type Hint를 도입하고 있습니다. 여담인데 Java같은 static type check을 하는 언어들은 dynamic type check의 장점들을 도입하고 있고 (var), Python (type hint)이나 Javacript(Typescript)같은 언어들은 static type check의 장점들을 도입하고 있습니다.

함수

Python의 함수들은 당연히 callable입니다.

def f():
  return 'Hi'

print(callable(f))
True

Class

Python의 (instance가 아닌) Class도 callable입니다.

class Cat():
  def __init__(self, age):
    self.age = age

print(callable(Cat))
True

아래와 같이 Class의 Instance를 생성할 때 Class를 함수처럼 호출합니다.

class Cat():
  def __init__(self, age):
    self.age = age

my_cat = Cat(5)

instance

위 예제의 my_cat은 callable일까요?

class Cat():
  def __init__(self, age):
    self.age = age

my_cat = Cat(5)
print(callable(my_cat))
False

예상했듯이 아니네요. my_cat()처럼 호출할 일은 없으니까요.

__call__()

하지만 필요하다면 my_cat을 callable으로 만들 수 있습니다. Class에 __call__() 메소드를 추가해주면 됩니다.

class Cat():
  def __init__(self, age):
    self.age = age

  def __call__(self):
    print('I am {} years old.'.format(self.age))

my_cat = Cat(5)
print(callable(my_cat))
my_cat()
True
I am 5 years old.

변수명을 마치 함수처럼 호출하면 Python은 해당 객체가 __call__()을 가지고 있는지 보고 있다면 __call()__을 호출합니다. 아니라면 XXX is not callable.이라는 에러가 발생합니다.

__call__()은 언제 유용할까?

그렇다면 __call__()은 언제 써야할까요? 사실 __call__()은 쓸 일이 많지 않습니다. 함수나 메소드를 정의해서 쓰는 것이 훨씬 직관적이니까요. 하지만 가끔 __call()__을 사용하는 것이 코드를 읽기 쉽게 해줄 때도 있습니다.

class Logger():
  def __call__(self, message):
    self.log(message)

  def log(self, message):
    print('Loging:', message)

logger = Logger()
logger.log('Event happend!')

위와 같이 로깅을 할 수도 있지만 아래와 같은 코드가 더 읽기 쉬울 수도 있습니다.

class Logger():
  def __call__(self, message):
    self.log(message)

  def log(self, message):
    print('Loging:', message)

logger = Logger()
logger('Event happend!')

또 다른 예는 Deep Learning 분야에서 유명한 PyTorch에서 볼 수 있습니다. PyTorch를 쓰다보면 아래와 같은 코드를 자주 보고 쓰게 됩니다.

class NeuralNetwork(nn.Module):
    def __init__(self):
        super(NeuralNetwork, self).__init__()
        self.flatten = nn.Flatten()
        self.linear_relu_stack = nn.Sequential(
            nn.Linear(28*28, 512),
            nn.ReLU(),
            nn.Linear(512, 512),
            nn.ReLU(),
            nn.Linear(512, 10),
        )

    def forward(self, x):
        x = self.flatten(x)
        logits = self.linear_relu_stack(x)
        return logits

device = "cuda" if torch.cuda.is_available() else "cpu"
model = NeuralNetwork().to(device)

X = torch.rand(1, 28, 28, device=device)
logits = model(X)

logits = model(X)__call__()이 쓰인 것을 알 수 있습니다. model은 instance이기 때문에 기본적으로 callable이 아니지만 PyTorch의 nn.Module Class가 내부적으로 __call__()을 정의해두었습니다. model.forward(X) 대신에 model(X)를 사용하는 장점은 두가지가 있습니다.

  • model(X)model.forward(X)보다 간결하고 읽기 좋습니다.
  • nn.Module.__call__()foward()를 호출하기 전후에 필요한 함수들을 호출할 수 있습니다.

마무리

callable이라는 개념, callable() 함수, __call__() 함수는 일반적인 코딩을 하다보면 접할 일이 많지 않습니다. 아마 TypeError: XXX object is not callable이라는 에러 메시지가 그나마 가장 흔한 경우가 아닐까합니다. 하지만 언어의 내부 작동 원리를 잘 이해할수록 더 효율적이고 좋은 코드를 쓸 수 있다고 생각합니다. 이 글이 Python 이해에 조금이나마 도움이 됐으면 하는 바람입니다.