들어가기 전
- 이 글은 이펙티브 파이썬을 공부하며 작성한 글입니다
- 코드 : https://github.com/chickenchickenlove/effective-python/tree/master/item39
요약
- 파이썬 클래스에서는 생성자가 __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라는 객체를 함수 내에서 명시적으로 사용하고 있기 때문이다.
- PathInputData는 InputData를 상속받은 자식 클래스다.
- 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()
먼저 데이터 클래스를 생성한다.
- InputData 클래스는 인터페이스를 제공하는 부모 클래스다.
- 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 클래스를 생성한다.
- Worker 클래스는 인터페이스만 제공하는 부모 클래스다.
- 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 함수를 구현했다. 그런데 이 코드에는 문제가 존재한다.
- generate_inputs → 명시적으로 PathInputdata 클래스를 사용함.
- 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))
- PathInputData에 @classmethod로 generate_inputs() 메서드를 구현했다.
- 클라이언트 관점에서는 클래스 계층 구조에서 동일한 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
- Worker 클래스도 @classmethod로 create_workers() 메서드를 구현해준다. 이 때, 파라메터 cls가 제공되는데, cls에 전달된 클래스 이름에 따라 동적으로 인스턴스가 생성된다.
- 즉, 다형성을 제공할 수 있게 된다.
# 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() 코드를 보면 '제네릭'하게 변형된 함수를 잘 볼 수 있다.
- map_reduce()를 호출하는 클라이언트는 어떤 Worker, InputData 클래스를 사용할지 클래스 이름을 전달하면 된다.
- Worker, InputData는 각각 @classmethod를 이용해 제네릭한 인스턴스를 제공한다. 즉, 다형성을 제공한다.
이렇게 코드가 개선되면 Worker / InputData 클래스에 새로운 자식 클래스가 추가되어도 map_reduce() 메서드에는 아무런 변화도 필요하지 않게 된다. 왜냐하면 파라메터로 전달되는 '클래스 다형성'을 통해서 모든 것이 해결되기 때문이다.