파이썬 코딩의 기술 - 39

JinWooHyun·2021년 7월 18일
0

객체를 제네릭하게 구성하려면 @classmethod를 통한 다형성을 활용하라.

제네릭 (Generic)

  • 파라미터의 타입이 나중에 지정되게하여 재활용성을 높일 수 있는 프로그래밍 스타일
  • 어떤 하나의 함수 (혹은 겉으로 보기에 이름이 다른 여러 다른 함수)가 여러 타입의 인자를 받고, 인자의 타입에 따라 적절한 동작을 하는 함수를 제네릭 함수라고 한다.
  • 파이썬은 동적 타입 언어이기 때문에 언어수준의 명시적인 제네릭 지원 기능은 없다.

파이썬에서는 객체뿐 아니라 클래스도 다형성을 지원한다.

다형성을 사용하면 계층을 이루는 여러 클래스가 자신에게 맞는 유일한 메서드 버전을 구현할 수 있다. 이는 같은 인터페이스를 만족하거나 같은 추상 클래스를 공유하는 많은 클래스가 서로 다른 기능을 제공할 수 있다는 뜻이다.

다형성 (polymorphism)

  • 같은 모양의 코드가 다른 동작을 하는 것
  • 다형성은 코드의 양을 줄이고, 여러 객체 타입을 하나의 타입으로 관리가 가능하여 유지보수에 좋음
  • Method Override 도 다형성의 한 예이다.

예를 들어, 맵리듀스(MapReduce) 구현을 작성하는데, 입력 데이터를 표현할 수 있는 공통 클래스가 필요하다고 하자. 다음 코드에서는 이럴 때를 위한 하위 클래스에서 다시 정의해야만 하는 read 메서드가 들어 있는 공통 클래스를 보여준다.

class InputData:
    def read(self):
        raise NotImplementedError

InputData의 구체적인 하위 클래스를 만들면서 디스크에서 파일을 읽게 할 수 있다.

class PathInputData(InputData):
    def __init__(self, path):
        super().__init__()
        self.path = path

    def read(self):
        with open(self.path) as f:
            return f.read()

PathInputData와 같이 얼마든지 InputData의 하위 클래스를 만들 수 있다. 각 하위 클래스는 처리할 데이터를 돌려주는 공통 read 인터페이스를 구현해야 한다. 어떤 InputData의 하위 클래스는 네트워크에서 데이터를 읽을 수 있고, 또 다른 하위 클래스는 읽어온 압축된 데이터를 투명하게 풀어 제공할 수도 있다.

비슷한 방법으로, 이 입력 데이터를 소비하는 공통 방법을 제공하는 맵리듀스 작업자(worker)로 쓸 수 있는 추상 인터페이스를 정의해보자.

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

다음 코드는 새줄 문자의 개수를 세는 맵리듀스 기능을 구현하는 Worker의 구체적인 하위 클래스이다.

class LineCountWorker(Worker):
    def map(self):
        data = self.input_data.read()
        self.result = data.count('\n')

    def reduce(self, other):
        self.result += other.result

이처럼 이해하기 쉬운 인터페이스와 추상화를 제공하는 클래스를 만들었지만, 객체를 생성해 활용해야만 이 클래스들이 쓸모 있게 된다. 각 객체를 만들고 맵리듀스를 조화롭게 실행하기 위한
가장 간단한 접근 방법은 도우미 함수를 활용해 객체를 직접 만들고 연결하는 것이다.

import os

def generate_inputs(data_dir):
    for name in os.listdir(data_dir):
        yield PathInputData(os.path.join(data_dir, name))

def create_workers(input_list):
    workers = []
    for input_data in input_list:
        workers.append(LineCountWorker(input_data))
    return workers

Worker 인스턴스의 map 단계를 여러 스레드에 공급해서 실행할 수 있다. 그 후 reduce를 반복적으로 호출해 결과를 최종 값으로 합칠 수 있다.

form threading import Thread

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 = workers
    for worker in rest:
        first.reduce(worker)
    return first.result

마지막으로 지금까지 만든 것들 한 함수 안에 합쳐서 각 단계를 실행한다.

def mapreduce(data_dir):
    inputs = generate_inputs(data_dir)
    workers = create_workers(inputs)
    return execute(workers)

몇 가지 입력 파일을 대상으로 이 함수를 실행해보면

import os
import random

def write_test_files(tmpdir):
    os.makedirs(tmpdir)
    for i in range(100):
        with open(os.path.join(tmpdir, str(i)), 'w') as f:
            f.write('\n' * random.randint(0, 100))
            
tmpdir = 'test_inputs'
write_test_files(tmpdir)

result = mapreduce(tmpdir)
print(f'총 {result} 줄이 있습니다.')

>>>5474 줄이 있습니다.

잘 동작한다. 하지만 위 mapreduce 함수는 제네릭(generic)하지 않다는 단점이 있다. 다른 InputDataWorker 하위 클래스를 사용하고 싶다면 각 하위 클래스에 맞게 generate_inputs, create_workers, mapreduce를 재작성해야 한다.

다른 언어에서는 다형성을 활용해 이 문제를 해결할 수 있다. InputData의 모든 하위 클래스는 맵리듀스를 처리하는 도우미 메서드들이 제네릭하게 사용할 수 있는 특별한 생성자(팩토리 메서드와 비슷한)를 제공한다. 파이썬에서는 생성자 메서드가 __init__밖에 없다는 점이 문제다. InputData의 모든 하위 클래스가 똑같은 생성자만 제공해야 하는 것은 불합리하다.

이 문제를 해결하기 위해 클래스 메서드(classmethod) 다형성을 사용해야 한다. 이 방식은 InputData.read에서 사용했던 인스턴스 메서드 다형성과 똑같은데, 클래스로 만들어낸 개별 객체에 적용되지 않고 클래스 전체에 적용된다는 점만 다르다.

@classmethod가 적용된 클래스 메서드는 공통 인터페이스를 통해 새로운 InputData 인스턴스를 생성한다.

class GenericInputData:
    def read(self):
        raise NotImplementedError
    
    @classmethod
    def generate_inputs(cls, config):
        raise NotImplementedError

generate_inputsGenericInputData의 구체적인 하위 클래스가 객체를 생성하는 방법을 알려주는 설정 정보가 들어 있는 딕셔너리를 파라미터로 받는다.

class PathInputData(GenericInputData):
    ...
    
    @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))

비슷한 방식으로 GenericWorker 클래스 안에 create_workers 도우미 메서드를 추가할 수 있다.

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 crate_workers(cls, input_class, config):
        workers = []
        for input_data in input_class.generate_inputs(config):
            workers.append(cls(input_data))
        return workers

여기서 input_class.generate_inputs 호출이 클래스 다형성의 예이다. create_workers__init__ 메서드를 직접 호출하지 않고, cls()를 호출함으로써 다른 방법으로 GenericWorker 객체를 만들 수 있다는 것도 알 수 있다.

이런 변경이 구체적인 GenericWorker 하위 클래스에 미치는 영향은 부모 클래스를 바꾸는 것뿐이다.

class LineCountWorker(GenericWorker):
    ...

마지막으로 mapreduce 함수가 create_workers를 호출하게 변경해서 mapreduce를 완전한 제네릭 함수로 만들 수 있다.

def mapreduce(worker_class, input_class, config):
    workers = worker_class.create_workers(input_class, config)
    return execute(workers)

config = {'data_dir' : tmpdir}
result = mapreduce(LintCountWorker, PathInputData, config)
print(f'총 {result} 줄이 있습니다.')

>>>5474 줄이 있습니다.

이제는 각 하위 클래스의 인스턴스 객체를 결합하는 코드를 변경하지 않아도 GenericInputDataGenericWorker 하위 클래스를 원하는 대로 작성할 수 있다.

기억해야 할 내용

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

0개의 댓글