Python

Python Reflection 맛보기

둔진 2020. 6. 9. 07:52

Introspection vs Reflection

Python이 가지는 매력 중의 하나는 언어의 많은 부분이 동적이라는 점입니다. Instrospec과 Reflection이 그런 기능 중 일부인데요. Introspection은 실행 시에 object에 대한 정보를 알아내는 것, Reflection은 여기에서 그치지 않고 object에 대한 정보를 수정하는 것을 의미합니다. 엄밀히 두가지의 의미가 다르지만 Reflection으로 합쳐서 부르는 경우가 많습니다.

Reflection 기능은 자주 쓰이지는 않지만 알아두면 유용할 때가 있습니다. 예를 들어, 코드 작성 시에는 함수 이름을 모르지만 실행 중에 함수 이름을 알아내서 실행시킨다거나, Python의 Duck typing을 통과하기 위해서 attribute을 추가하는 일을 할 수 있습니다.

하지만 실행시에 동적으로 코드의 일부가 수정되는 셈이기 때문에 IDE, 컴파일과 같은 정적 분석 단계에서 쓸 수 있는 많은 툴을 사용하지 못 한다는 단점이 있습니다.

(Python에 왠 컴파일이냐? pyc 파일에 대해서를 참고해주세요.)

그럼 이런 주의사항을 마음에 담아두고, 몇가지 Reflection 기능을 알아보겠습니다.

dir

먼저 object에 어떤 attribute들이 있는지 알 수 있는 dir() 입니다. object는 attribute들의 집합이라고 할 수 있습니다.

In : s = 'Hello'

In : dir(s)
Out:
['__add__',
 '__class__',
 '__contains__',
...
 '__len__',
...
 'join',
 'ljust',
 'lower',
...
 'zfill']

내용이 길어서 일부 결과는 생략했습니다. attribute 중에 __len__ 이나 join 처럼 익숙한 것들도 보입니다.

dir 함수는 해당 object가 가진 모든 attribute의 이름을 list로 반환해줍니다. Python의 모든 것은 object이기 때문에 dir을 사용해서 모든 것의 내용을 들여다볼 수 있습니다.

In : dir(s.join)
Out:
['__call__',
 '__class__',
 '__delattr__',
 '__dir__',
...
 '__subclasshook__',
 '__text_signature__']

함수인 str.join()도 object이기 때문에 내부적으로 다양한 object를 가지고 있습니다.

type

type() 함수

모든 object는 type을 가집니다. type() 을 사용하면 해당 object의 type을 알 수 있습니다.

In : a = []

In : type(a)
Out: list

당연하게도 a의 type은 list입니다.

type type

그렇다면 list의 type은 뭘까요? list는 type이라는 type을 가집니다.

In : type(type(a))
Out: type

Reflection의 문제가 바로 이겁니다. 아무래도 언어 자체에 대해서 이야기를 하다보니 중복되거나 비슷한 용어를 섞어서 쓰게됩니다. 정말 헷갈리는 일이죠. 벌써 세가지 의미로 type을 사용했습니다.

  • 일반적인 명사로서의 type: "이 object의 type은 list이다"처럼 프로그래밍 언어 관점에서의 type
  • type() 함수: 특정 object의 type(위의 뜻)이 무엇인지 알아내는 함수
  • type type (오타 아님): 특정 type을 표현하는 type. type() 함수의 반환값의 type.

위 세 문장을 쓰는데 type이라는 단어를 11번 사용했습니다. 특히 마지막 항목은 쓰면서도 이게 무슨 말인지 싶네요.

역시 이럴 때는 예를 들어보는 것이 최고죠.

  • a = list() -> list type의 object를 하나 만들고 a에 할당합니다.
  • type(a)list type입니다.
  • Python에서는 모든 것이 객체라고 했기 때문에 그럼 list type도 무언가의 object여야합니다.
  • list type은 type type의 object입니다.
  • 틀린 문법이지만 a = list()와 같은 맥락에서 본다면 list = type()인 셈입니다.
  • 그렇다면 type type도 object일 겁니다. type type의 type은 type type입니다. (그만...)

예가 도움이 안 되는군요.

참고로 type은 보통 object.__class__ 를 반환합니다.

In : a.__class__
Out: list

type도 엄연히 type이고 object가 될 수 있기 때문에 위에 dir 을 사용할 수 있습니다.

In : dir(type(type(a)))
Out:
['__abstractmethods__',
 '__base__',
 '__bases__',
 '__basicsize__',
 '__call__',
 '__class__',
 '__delattr__',
 '__dict__',
 ...

역시 attribute으로 __class__가 있는 것을 볼 수 있습니다. type 이야기는 여기까지만...

isinstance() 함수

object가 특정 type인지 알아보려면 isinstance() 함수를 사용합니다. (type 이야기하는거 아닙니다.)

In : isinstance(1, int)
Out: True

isinstance() 함수의 두번째 인자는 tuple을 받을 수 있습니다. tuple 의 원소 중 하나와 일치하면 True를 반환합니다.

In : isinstance(1, (int, str, list))
Out: True

isinstance() 함수는 type의 부모 클래스에 대해서도 True를 반환합니다.

In : class A:
    ...:     pass
    ...:

In : class B(A):
    ...:     pass
    ...:

In : b = B()

In : isinstance(b, B)
Out: True

In : isinstance(b, A)
Out: True

하지만 type을 직접 비교하면 상속 관계를 따지지 않습니다.

In : type(b) is B
Out: True

In : type(b) is A
Out: False

Duck Typing

isinstace() 가 나온 김에 Duck Typing에 대해서 이야기해보려고 합니다.

Java나 C++처럼 Static Type Check이 매우 강한 언어에서 오신 분들은 Duck Typing이 처음에는 매우 낯섭니다. Duck Typing은 object의 type 자체 보다는 그 object가 할 수 있는 일이 무엇인지에 초점을 맞춥니다.

Java

예를 들어 새에 대한 클래스를 만든다고 가정해보겠습니다. Java라면 클래스 설계를 대략 이렇게 시작합니다.

  • DuckSparrow가 필요한데 공통으로 Bird 클래스를 하나 만들자.
  • 새는 걸을 수 있으니 walk()Bird 클래스에 넣자.
  • 어떤 새는 날 수 있고, 어떤 새는 날 수가 없으니 Flyable Interface를 하나 만들어서 구분하자.
  • SparrowFlyable을 구현(implement)하자.
interface Flyable {
    void fly();
}

class Bird {
    public void walk() {
        System.out.println("Walking!");
    }
}

class Duck extends Bird {
    public void speak() {
        System.out.println("Quack!");
    }
}

class Sparrow extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("I'm flying.");
    }
}

DuckSparrow 모두 Bird 를 상속하지만 SparrowFlyable 을 구현하고 있습니다.

이 클래스들을 사용하는 예를 한번 보겠습니다.

public class Main {
    public static void walk(Bird bird) {
        bird.walk();
    }

    public static void fly_1(Bird bird) {
        if (bird instanceof Sparrow) {
            ((Sparrow) bird).fly();
        }
    }

    public static void fly_2(Flyable flyable) {
        flyable.fly();
    }


    public static void main(String[] args) {
        Sparrow sparrow = new Sparrow();
        Duck duck = new Duck();

        walk(sparrow);
        walk(duck);

        fly_1(sparrow);
        fly_1(duck);

        fly_2(sparrow);
//        fly_2(duck);
    }
}

walk()Bird 클래스에 공통이기 때문에 Sparrow, Duck 모두 문제없이 쓸 수 있습니다. Flyable.fly()Bird에는 없고 Flyable에만 있기 때문에 Type Check를 해야합니다. 많이 쓰는 Type Check 방법은 두가지입니다.

  1. instanceof 를 사용해서 해당 object가 특정 type인지 확인 후에 사용. fly_1에서 이 방법을 사용하고 있습니다.
  2. 함수의 인자에 type을 명시해서 애초에 그 type만 받습니다. fly_2에서 이 방법을 사용합니다. fly_2(duck)에서 duckFlyable이 아니기 때문에 컴파일 에러가 납니다.

2번 방식이 Strong type checking을 하는 언어들의 장점이자 권장하는 방법입니다. 버그는 가능한 일찍, 특히 컴파일 단계에서 잡을수록 좋기 때문입니다.

Duck Typing

히지만 Type Checking에 대해 다른 의견도 있습니다. 기능을 구현하는 관점에서 Type의 형식(이름)이 무엇이냐보다는 그 Type이 할 수 있는 일이 무엇인지가 더 중요하다는 주장입니다. 예를 들어 어떤 클래스가 Flyable을 상속받았든, DoFly를 상속받았든, CanFly를 상속받았든 중요하지 않고, 그 클래스가 어쨌든 fly()만 할 수 있다면 OK라는 입장입니다. 다른 표현으로 어떤 새가 "오리처럼 걷고, 오리처럼 꽥꽥 거리고, 오리처럼 못 난다면 (다른 것은 관계없고) 그것은 오리다"라고도 합니다. Duck Typing의 유래이기도 하고요.

위의 Java 코드를 Python으로 재구현해보겠습니다.

class Bird:
    def walk(self):
        print('Walking!')

class Sparrow(Bird):
    def fly(self):
        print("I'm flying.")

class Duck(Bird):
    pass

def walk(bird):
    bird.walk()

def fly_1(bird):
    if type(bird) is Sparrow:
        bird.fly()

def fly_2(bird):
    bird.fly()

sparrow = Sparrow()
duck = Duck()

walk(sparrow)
walk(duck)

fly_1(sparrow)
fly_1(duck)

fly_2(sparrow)
# fly_2(duck)

Java의 Interface를 쓰지 않고 fly()를 Sparrow 클래스 내에 구현했지만 기본적인 틀은 같습니다. fly_1()type() 함수를 이용해서 birdSparrow인지 먼저 확인하고 맞는 경우에만 fly()를 호출합니다. fly_2()는 그냥 fly()를 호출합니다. 여기에 Java와 같은 Strong type checking 언어와 가장 큰 차이가 있습니다. Python과 같은 Dynamic type 언어는 Java와 달리 함수 인자의 Type checking을 엄밀하게 하지 않습니다. fly_2()의 구현을 보면 인자의 type이 뭐가됐든 fly() 함수만 가지고 있다면 난 OK라는 의미를 내포하고 있습니다. 즉, 오리가 할 수 있는 일들만 할 수있다면 난 그 새를 오리 취급하겠다(Duck typing)는 의미입니다.

이런 접근법에는 장점과 단점이 있습니다. 장점은 형식적인 type을 굳이 맞출 필요가 없기 때문에 빠른 개발이 가능하다는 점입니다. 단점은 실행시에 해당 함수가 있는지 보장이 되지 않기 때문에 프로그램이 실패할 수 있다는 점입니다. 이 경우에도 주석 처리된 # fly_2(duck) 부분을 주석을 풀면 실행 시작은 문제없이 되지만, 저 부분을 막상 실행시키려고하면 에러가 발생합니다.

Traceback (most recent call last):
  File "duck.py", line 32, in <module>
    fly_2(duck)
  File "duck.py", line 20, in fly_2
    bird.fly()
AttributeError: 'Duck' object has no attribute 'fly'

이 두 방식 중에 뭐가 맞느냐는 사실 답이 없습니다. 프로그램의 안정성과 개발 편의성 사이의 Trade off이니까요. 양쪽 다 장점이 있기 때문에 Java 같은 언어는 Strong type checking을 유지하면서 개발자의 수고로움을 덜어주기 위해서 Type inference를 많이 추가했고, Python도 3.5부터 Type hint 기능을 제공하고 있습니다. (또다른 Dynamic 언어인 Javascript는 Typescript이라는 Type check가 강화된 확장이 있습니다.) 서로의 장점을 취하면서 발전하고 있는 셈입니다.

getattr()

앞에서 이야기한대로 dir()을 사용하면 object에 포함된 모든 attribute들의 이름을 알아낼 수 있습니다. 이름만이 아니라 실제로 그 attribute가 필요하다면 getattr()함수를 사용하면 됩니다.

In : s = 'Hello'

In : getattr(s, 'upper')()
Out: 'HELLO'

위와 같이하면 s.upper()와 같은 효과를 얻을 수 있습니다. 언제 이 기능을 사용할까요? 함수 이름으로 함수 호출하기에서 소개한것처럼, 함수의 이름을 실제 프로그램 실행 시에 동적으로 결정하고 싶을 때 사용할 수 있습니다.

setattr

지금까지 예는 Intropection에 가까웠습니다. 기존 object 내부 정보를 알아내기만 했으니까요. setattr() 함수를 사용하면 object의 내용을 동적으로 바꿀 수 있습니다. 예를 들어 앞의 Sparrow, Duck 예에서 fly_2(duck)은 에러가 발생하지만 아래와 같이 수정하면 정상적으로 호출이 됩니다.

setattr(Duck, 'fly', lambda self: print("I'm flying"))
fly_2(duck)

fly_2(duck)을 호출하기 전에 Duck class에 fly라는 이름으로 함수를 추가했습니다. (* fly() 함수를 Duck 클래스의 메소드로 추가하고 있기 때문에 인자로 self를 주었습니다.)

callable

callable()함수를 사용하면 어떤 object를 함수처럼 호출할 수 있는지 알 수 있습니다. 예를 들어 위 예에서 duck은 함수 형태로 호출할 수가 없습니다. 함수가 아니니까요. callable(duck)을 호출하면 False를 반환합니다. callable(walk)True입니다. walk()는 함수니까요.

하지만 함수만 호출할 수 있는 것은 아닙니다. Python에서는 좀 더 범용적으로 object가 함수인지 아닌지가 아니라 callable인지 아닌지를 따집니다. 물론 함수는 callable입니다. 또다른 callable로는 Class가 있습니다. 조금 헷갈리기는 하지만 Class의 instance를 만들 때 이미 사용하고 있습니다. 예를 들어 duck = Duck()을 보면 Duck 클래스를 마치 함수처럼 호출하고 있습니다. 다른 언어처럼 new 키워드가 별도로 없습니다. 이 경우는 인자를 받지 않고 있지만 당연히 인자도 받을 수 있습니다. Duck()을 호출하면 Duck object를 하나 만들어서 반환해줍니다. 인자를 받아서 반환값을 돌려준다는 점에서 함수의 동작과 동일합니다. callable(Duck)True입니다.

여기에 추가로 강력한 Dynamic 언어답게 모든 object를 함수처럼 호출하게 만들 수 있습니다. Python은 object에 __call__ 함수가 있으면 이 object를 callable으로 간주합니다. 처음 Duck 클래스를 정의 할 때 __call__ 함수를 선언해도 되지만, 여기에서는 앞의 setattr()을 활용해보겠습니다.

print(callable(duck))
setattr(Duck, '__call__', lambda self, n: print('quack! ' * n))
print(callable(duck))
duck(5)
False
True
quack! quack! quack! quack! quack!

마무리

Reflection은 매우 강력한 도구이지만 동시에 단점도 매우 강한 도구입니다. Reflection이 실행 시에 이루어지기 때문에 IDE나 컴파일러가 사전에 버그를 잡을 수가 없습니다. 또 일반적인 코드에 비해 코드의 가독성도 떨어집니다. Reflection의 장단점을 잘 이해하시고 적절한 곳에 사용하시길 바랍니다.

'Python' 카테고리의 다른 글

Jupyter Lab  (0) 2021.05.01
*args, **kwargs  (2) 2020.06.23
pyc 파일에 대해서  (5) 2020.06.03
-m 실행 옵션과 __name__  (5) 2020.05.28
venv는 내부적으로 어떻게 작동할까?  (2) 2020.05.19