Effective Python Item 39. @classmethod를 통해 클래스 다형성을 이용해라.

    들어가기 전

     


    요약

    • 파이썬 클래스에서는 생성자가 __init__ 메서드 뿐이다.
    • @classmethod를 사용하면 클래스에 다른 생성자를 정의할 수 있다. 
    • 클래스 메서드 다형성을 활용하면 여러 구체적인 하위 클래스의 객체를 만들고 연결하는 제네릭한 방법을 제공할 수 있다. 

     


    Effective Python Item 39. 객체를 제네릭하게 구성하려면 @classmethod를 통해 클래스 다형성을 활용하라

    다형성은 객체, 클래스가 같은 '인터페이스를 제공'하는 경우에 서로 다른 기능을 제공할 수 있는 성질을 의미한다. 파이썬은 @classmethod를 이용해 클래스에서도 다형성을 제공할 수 있다. 한 가지 코드 예시를 들어보자.

    class InputData:
        def read(self):
            raise NotImplementedError
    
    
    class PathInputData(InputData):
    
        def __init__(self, path):
            self.path = path
    
        def read(self):
            with open(self.path, 'r') as f:
                return f.read()
    
    def generate_inputs(data_dir):
        for name in os.listdir(data_dir):
            # 명시적으로 PathInputdata를 사용함. Generic 하지 않음
            yield PathInputData(os.path.join(data_dir, name))

    이런 코드가 있다고 가정해보자. 여기서 generate_inputs()은 제네릭하지 않은 함수다. 왜냐하면 PathInputData라는 객체를 함수 내에서 명시적으로 사용하고 있기 때문이다. 

    1. PathInputData는 InputData를 상속받은 자식 클래스다.
    2. InputData는 Path에서도 읽어올 수 있고, 네트워크를 통해서도 읽어올 수 있음. (방법이 무궁무진함) 

    정리해보면 InputData는 확장 가능성이 있는 클래스이지만, 이것을 사용하는 generate_inputs()는 명시적으로 PathInputData를 사용하기 때문에 확장가능하지 않다. 

    그렇다면 generate_inputs()에 다형성을 지원하는 파라메터를 넣고 객체를 생성하는 것이 좋을 것이다. 그런데 파이썬에서 객체를 생성하는 생성자는 __init__ 메서드 뿐이다. 파이썬 클래스 계층 구조에서 모든 부모 - 자식 클래스가 동일한 __init__() 인터페이스를 구현하는 것은 불가능하다. (나쁜 코드다, 특정 파라메터가 변하면 모든 코드가 변해야하니). 

    # @classmethod를 이용해 동적 클래스에 대한 인스턴스 생성. 
    @classmethod
    def create_workers(cls, input_list):
        workers = []
        for input_data in input_list:
            workers.append(cls(input_data))
        return workers

    __init__메서드를 통일할 수 없으니, 이를 이용해 객체를 생성하는 것보다 @classmethod를 이용하면 cls()를 통해 클래스를 동적으로 생성할 수 있다. 즉, 클래스 다형성을 제공한다는 것이다. 이 클래스 다형성을 제공하면 제네릭하지 않은 함수들도 제네릭하게 만들 수 있다. 

     

    자세한 것은 아래에서 코드를 보며 이해해보자. 


    코드

    여기서는 Map - Reduce 기능을 호출하는 객체들을 만들 것이다.

    class InputData:
        def read(self):
            raise NotImplementedError
    
    
    class PathInputData(InputData):
    
        def __init__(self, path):
            self.path = path
    
        def read(self):
            with open(self.path, 'r') as f:
                return f.read()

    먼저 데이터 클래스를 생성한다.

    1. InputData 클래스는 인터페이스를 제공하는 부모 클래스다.
    2. PathInputData는 로컬 파일에서 데이터를 읽어오는 클래스다. 

    데이터는 로컬에서 읽을 수도 있으나, 언젠가는 네트워크를 통해서도 읽을 수 있을 것이다. InputData 관점에서는 확장 가능성이 존재한다. 

    class Worker:
        def __init__(self, input_data):
            self.input_data = input_data
            self.result = None
    
        def map(self):
            raise NotImplementedError
    
        def reduce(self, other):
            raise NotImplementedError
    
    
    class LineCountWorker(Worker):
    
        def map(self):
            data = self.input_data.read()
            self.result = data.count('\n')
    
        def reduce(self, other):
            self.result += other.result

    InputData를 읽어서 map / reduce하는 Worker 클래스를 생성한다. 

    1. Worker 클래스는 인터페이스만 제공하는 부모 클래스다.
    2. LineCountWorker는 주어진 InputData의 라인 수를 읽고 reduce 하는 기능을 제공하는 자식 클래스다. 

    현재는 라인 수만 읽도록 구현되었지만, 추후에는 특정 정규표현식에 일치하는 값들을 Map - Reduce 하는 기능이 추가될 수도 있다. Worker 클래스도 충분히 확장 가능성이 있는 클래스다. 

    def generate_inputs(data_dir):
        for name in os.listdir(data_dir):
            # 명시적으로 PathInputdata를 사용함. Generic 하지 않음
            yield PathInputData(os.path.join(data_dir, name))
    
    def create_workers(input_list):
        workers = []
        for input_data in input_list:
            # 명시적으로 LineCountWorker를 사용함. Generic 하지 않음.
            workers.append(LineCountWorker(input_data))
        return workers
    
    
    def execute(workers):
        threads = [Thread(target=w.map) for w in workers]
        for thread in threads: thread.start()
        for thread in threads: thread.join()
    
        first, *rest = threads
        for worker in rest:
            first.reduce(worker)
        return first.result

    Worker / InputData 클래스는 서로 상호작용을 하게 될 것이다. 서로 상호작용을 할 수 있도록 generate_inputs / create_workers / execute 함수를 구현했다. 그런데 이 코드에는 문제가 존재한다.

    1. generate_inputs → 명시적으로 PathInputdata 클래스를 사용함. 
    2. create_workers → 명시적으로 LineCountWorker 클래스를 사용함. 

    InputData / CountWorker는 확장 가능성이 있는 클래스들이다. 그러나 이 클래스들을 사용하는 generate_inputs / create_workers 함수들은 확장 불가능하다. InputData / Worker에 새로운 클래스가 추가될 때 마다 새로운 generate_inputs / create_workers 메서드를 구현해야한다. 

    이 문제의 핵심은 '제네릭하게 인스턴스를 생성할 수 있는 방법'이 없다는 것이다. 파이썬에서는 __init__만 생성자로 제공되는데, 계층 구조 내의 모든 클래스가 동일한 생성자 인터페이스를 제공할 수 없다. 각 클래스마다 서로 다른 인자를 생성자로 받는 경우가 더 많기 때문이다. 

    이 때 생성자가 __init__ 하나 뿐이라는 부분은 @classmethod를 이용하면 해결 할 수 있게 된다. 아래에서 코드를 하나씩 살펴보자.

    class GenericInputData:
        def read(self):
            raise NotImplementedError
    
        @classmethod
        def generate_inputs(cls, config):
            raise NotImplementedError
    
    
    class PathInputData(GenericInputData):
    
        def __init__(self, path):
            self.path = path
    
        def read(self):
            with open(self.path, 'r') as f:
                return f.read()
    
        @classmethod
        def generate_inputs(cls, config):
            data_dir = config['data_dir']
            for name in os.listdir(data_dir):
                yield cls(os.path.join(data_dir, name))
    1. PathInputData에 @classmethod로 generate_inputs() 메서드를 구현했다. 
    2. 클라이언트 관점에서는 클래스 계층 구조에서 동일한 generate_inputs() 인터페이스를 제공하기 때문에 '클래스 다형성'으로 인스턴스를 생성할 수 있게 된다. 

    아래에서 좀 더 효과를 확인해 볼 수 있다. 

    class GenericWorker:
        def __init__(self, input_data):
            self.input_data = input_data
            self.result = None
    
        def map(self):
            raise NotImplementedError
    
        def reduce(self, other):
            raise NotImplementedError
    
        @classmethod
        def create_workers(cls, input_list):
            workers = []
            for input_data in input_list:
                workers.append(cls(input_data))
            return workers
    
    class LineCountWorker(GenericWorker):
    
        def map(self):
            data = self.input_data.read()
            self.result = data.count('\n')
    
        def reduce(self, other):
            self.result += other.result
    1. Worker 클래스도 @classmethod로 create_workers() 메서드를 구현해준다. 이 때, 파라메터 cls가 제공되는데, cls에 전달된 클래스 이름에 따라 동적으로 인스턴스가 생성된다.
    2. 즉, 다형성을 제공할 수 있게 된다. 
    # map_reduce 함수는 worker 클래스, input 클래스를 전달받아서 class Method를 호출한다.
    # 따라서, 이전에 LineWorker처럼 명시해서 하던 것과 비교했을 때 제네릭한 코드가 되었다.
    def map_reduce(worker_class, input_class, config):
        input_list = input_class.generate_inputs(config)
        workers = worker_class.create_workers(input_list)
        return execute(workers)

    map_reduce() 코드를 보면 '제네릭'하게 변형된 함수를 잘 볼 수 있다. 

    1. map_reduce()를 호출하는 클라이언트는 어떤 Worker, InputData 클래스를 사용할지 클래스 이름을 전달하면 된다.
    2. Worker, InputData는 각각 @classmethod를 이용해 제네릭한 인스턴스를 제공한다. 즉, 다형성을 제공한다. 

    이렇게 코드가 개선되면 Worker / InputData 클래스에 새로운 자식 클래스가 추가되어도 map_reduce() 메서드에는 아무런 변화도 필요하지 않게 된다. 왜냐하면 파라메터로 전달되는 '클래스 다형성'을 통해서 모든 것이 해결되기 때문이다. 

    댓글

    Designed by JB FACTORY