파이썬은 inheritance, polymorphism, encapsulation 과 같은 기능을 제공하고, 클래스와 상속을 잘 사용하면 유지 보수가 용이하고, 기능의 확장에 있어 유연성을 발휘할 수 있다.
Item 37: 여러 level의 buit-in type을 nesting하기보다는 클래스를 결합하라
파이썬의 내장 딕셔너리 타입을 사용하면 객체의 lifetime동안 동적인 내부상태를 유지할 수 있다.
예를 들어, 학생들의 점수를 기록하고, 학생의 이름을 미리 알 수 없는 상황이라면 딕셔너리에 이름을 저장하는 클래스를 정의할 수가 있다.
class SimpleGradebook:
def __init__(self):
self._grades = {}
def add_student(self, name):
self._grades[name] = []
def report_grade(self, name, score):
self._grades[name].append(score)
def average_grade(self, name):
grades = self._grades[name]
return sum(grades) / len(grades)
book = SimpleGradebook()
book.add_student('Isaac Newton')
book.report_grade('Isaac Newton', 90)
book.report_grade('Isaac Newton', 95)
book.report_grade('Isaac Newton', 85)
print(book.average_grade('Isaac Newton'))
>>>
90.0
딕셔너리와 같은 내장 타입은 쓰기 쉽기 때문에, 과도하게 확장하면서 위험한 코드를 작성할 수가 있음.
예를 들어, 전체 성적이 아니라 과목별 성적을 리스트로 저장하고 싶다면, _grades 딕셔너리를 수정해서 학생 이름이 다른 dictionary를 맵핑하고, 이 딕셔널가 다시 과목을 성적에 매핑하면 된다.
from collections import defaultdict
class BySubjectGradebook:
def __init__(self):
self._grades = {} # Outer dict
def add_student(self, name):
self._grades[name] = defaultdict(list) # Inner dict
def report_grade(self, name, subject, grade):
by_subject = self._grades[name]
grade_list = by_subject[subject]
grade_list.append(grade)
def average_grade(self, name):
by_subject = self._grades[name]
total, count = 0, 0
for grades in by_subject.values():
total += sum(grades)
count += len(grades)
return total / count
book = BySubjectGradebook()
book.add_student('Albert Einstein')
book.report_grade('Albert Einstein', 'Math', 75)
book.report_grade('Albert Einstein', 'Math', 65)
book.report_grade('Albert Einstein', 'Gym', 90)
book.report_grade('Albert Einstein', 'Gym', 95)
print(book.average_grade('Albert Einstein'))
>>>
81.25
아직은 그렇게까지 복잡하지 않다.
만약 점수의 가중치를 함께 저장해서 중간고사가 다른 시험보다 성적에 더 큰 영향을 미치고 싶은 경우를 생각해보자.
가장 안의 딕셔너리에서 과목을 성적 리스트로 매핑하던 것을 (성적, 가중치) 튜플 리스트로 매핑하도록 수정하면 된다.
class WeightedGradebook:
def __init__(self):
self._grades = {}
def add_student(self, name):
self._grades[name] = defaultdict(list)
def report_grade(self, name, subject, score, weight):
by_subject = self._grades[name]
grade_list = by_subject[subject]
grade_list.append((score, weight))
def average_grade(self, name):
by_subject = self._grades[name]
score_sum, score_count = 0, 0
for subject, scores in by_subject.items():
subject_avg, total_weight = 0, 0148 Chapter 5 Classes and Interfaces
for score, weight in scores:
subject_avg += score * weight
total_weight += weight
score_sum += subject_avg / total_weight
score_count += 1
return score_sum / score_count
book = WeightedGradebook()
book.add_student('Albert Einstein')
book.report_grade('Albert Einstein', 'Math', 75, 0.05)
book.report_grade('Albert Einstein', 'Math', 65, 0.15)
book.report_grade('Albert Einstein', 'Math', 70, 0.80)
book.report_grade('Albert Einstein', 'Gym', 100, 0.40)
book.report_grade('Albert Einstein', 'Gym', 85, 0.60)
print(book.average_grade('Albert Einstein'))
>>>
80.25
report_grade 메소드에서는 아주 단순한 변경만 일어났지만, average_grade에서는 아주 복잡한 loop가 쓰였고, 클래스를 쓸 때도 위치 인자를 지정했을 때 각 값이 무엇을 의미하는지 이해하기가 어렵다.
이렇게 복잡해질 경우 주저하지 않고 클래스를 사용하라는 것이 필자의 의견이다.
클래스를 이용해 Refactoring
점수와 가중치 튜플을 클래스로 표현할 수도 있겠지만 비용이 너무 많이 들기 때문에 튜플을 그대로 사용한다.
grades = []
grades.append((95, 0.45))
grades.append((85, 0.55))
total = sum(score * weight for score, weight in grades)
total_weight = sum(weight for _, weight in grades)
average_grade = total / total_weight
하지만 만약 튜플에 저장해야 할 값이(ex. 메모) 아래와 늘어난다면 다른 방법을 고민해봐야 한다.
grades = []
grades.append((95, 0.45, 'Great job'))
grades.append((85, 0.55, 'Better next time'))
total = sum(score * weight for score, weight, _ in grades)
total_weight = sum(weight for _, weight, _ in grades)
average_grade = total / total_weight
위의 경우에는 collection 내장 모듈의 namedtuple을 쓰는 것이 바람직하다.
from collections import namedtuple
Grade = namedtuple('Grade', ('score', 'weight'))
이제 점수를 포함하는 단일 과목 클래스를 정의해보자.
class Subject:
def __init__(self):
self._grades = []
def report_grade(self, score, weight):
self._grades.append(Grade(score, weight))
def average_grade(self):
total, total_weight = 0, 0
for grade in self._grades:
total += grade.score * grade.weight
total_weight += grade.weight
return total / total_weight
그 다음 한 학생이 수강하는 과목들을 표현하는 클래스를 정의하자.
class Student:
def __init__(self):
self._subjects = defaultdict(Subject)
def get_subject(self, name):
return self._subjects[name]
def average_grade(self):
total, count = 0, 0
for subject in self._subjects.values():
total += subject.average_grade()
count += 1
return total / count
마지막으로 모든 학생을 저장하는 container를 정의한다.
class Gradebook:
def __init__(self):
self._students = defaultdict(Student)
def get_student(self, name):
return self._students[name]
실제로 클래스를 정의하는 코드는 refactoring하기 전보다 두 배 이상 늘어났지만, 훨씬 가독성이 좋고 확장성이 좋다.
book = Gradebook()
albert = book.get_student('Albert Einstein')
math = albert.get_subject('Math')
math.report_grade(75, 0.05)
math.report_grade(65, 0.15)
math.report_grade(70, 0.80)
gym = albert.get_subject('Gym')
gym.report_grade(100, 0.40)
gym.report_grade(85, 0.60)
print(albert.average_grade())
>>>
80.25
Item 38: 간단한 interface의 경우에는 class 대신 function을 받을 것
파이썬 내장 API 중 상당수는 함수를 전달해 원하는 동작을 가능케한다. API가 실행되는 과정에서 전달한 함수를 실행하는 경우, 이 함수를 hook 이라고 부른다.
names = ['Socrates', 'Archimedes', 'Plato', 'Aristotle']
names.sort(key=len)
print(names)
>>>
['Plato', 'Socrates', 'Aristotle', 'Archimedes']
위는 key hook으로 len 내장 함수를 전달한 예이다.
이런 hook을 abstract class로 정의해야 하는 언어도 있지만, 파이썬은 함수를 first-class object로 취급하므로 hook으로 사용할 수 있다.
예를 들어, defaultdict 클래스의 동작을 customize 하고 싶다고 하자.
defaultdict에는 딕셔너리안에 없는 키에 접근할 경우, parameter가 없는 함수를 전달할 수 있다.
def log_missing():
print('Key added')
return 0
위 코드는 존재하지 않는 키에 접근할 때 log를 남기고 0을 default로 반환한다.
from collections import defaultdict
current = {'green': 12, 'blue': 3}
increments = [
('red', 5),
('blue', 17),
('orange', 9),
]
result = defaultdict(log_missing, current)
print('Before:', dict(result))
for key, amount in increments:
result[key] += amount
print('After: ', dict(result))
>>>
Before: {'green': 12, 'blue': 3}
Key added
Key added
After: {'green': 12, 'blue': 20, 'red': 5, 'orange': 9}
위처럼 log_missing과 같은 함수는 정해진 동작과 side effect를 분리하므로 API를 더 쉽게 만들 수 있다.
예를 들어, 방금과 같은 hook이 존재하지 않는 키에 접근한 총횟수를 세고 싶다고 하자. 이 때는 closure를 사용해야 하고, 이런 closure가 있는 helper function을 default 값 hook으로 사용한다.
def increment_with_report(current, increments):
added_count = 0
def missing():
nonlocal added_count # Stateful closure
added_count += 1
return 0
result = defaultdict(missing, current)
for key, amount in increments:
result[key] += amount
return result, added_count
result, count = increment_with_report(current, increments)
assert count == 2
하지만 이런 상태를 다루기 위한 hook으로 closure를 사용하면 이해하기가 어렵다. 다른 방법은 추적하고 싶은 상태를 저장하는 작은 클래스를 정의하는 것이다.
class CountMissing:
def __init__(self):
self.added = 0
def missing(self):
self.added += 1
return 0
다른 언어에서는 CountMissing이 제공하는 interface를 위해 defaultdict의 코드를 변경해야 할 수도 있지만, 파이썬은 first-class function을 이용해 객체에 대한 CountMissing.missing 메소드를 직접 defaultdict의 default값 훅으로 전달할 수 있다.
counter = CountMissing()
result = defaultdict(counter.missing, current) # Method ref
for key, amount in increments:
result[key] += amount
assert counter.added == 2
위의 코드가 더 깔끔하기는 하나, 클래스 자체만 놓고 보면 CountMissing 클래스의 목적이 무엇인지 분명히 알기는 힘들다.
이런 경우 더 명확한 표현을 위해 __call__ 이라는 special method를 정의할 수 있다. __call__을 사용하면 객체를 함수처럼 호출할 수 있다. 그리고 __call__이 정의된 클래스의 instance에 대해 callable 내장 함수를 호출하면, True가 반환된다. 이런 객체를 callable 객체라고 부른다.
class BetterCountMissing:
def __init__(self):
self.added = 0
def __call__(self):
self.added += 1
return 0
counter = BetterCountMissing()
assert counter() == 0
assert callable(counter)
아래 코드는 BetterCountMissing 인스턴스를 defaultdict의 디폴트 값 훅으로 사용한다.
counter = BetterCountMissing()
result = defaultdict(counter, current) # Relies on __call__
for key, amount in increments:
result[key] += amount
assert counter.added == 2
훨씬 깔끔하다.
__call__ 메소드는 함수가 인자로 쓰일 수 있는 부분에 클래스의 instance를 사용할 수 있다는 사실을 보여준다. 이 클래스의 목적이 상태를 저장하는 closure 역할임도 쉽게 알 수 있다. 그리고 무엇보다 좋은 점은 defaultdict가 __call__ 내부에서 어떤 일이 벌어지는지 전혀 알 필요가 없다는 것이다.
Item 39: 객체를 generic하게 구성하려면 @classmethod를 통한 다형성을 이용해라
파이썬에서는 객체뿐 아니라 클래스도 다형성을 지원한다
예를 들어, MapReduce 구현에, 입력 데이터를 표현할 수 있는 공통 클래스가 필요하다고 하자.
다음 코드는 이런 경우 사용하기 위해 정의한, 하위 클래스에서 다시 정의해야 하는 read 메소드가 있는 공통 클래스이다.
class InputData:
def read(self):
raise NotImplementedError
이 InputData의 구체적인 subclass를 만들면서 디스크에서 파일을 읽게 할 수 있다.
class PathInputData(InputData):
def __init__(self, path):
super().__init__()
self.path = path
def read(self):
with open(self.path) as f:
return f.read()
이처럼 각 subclass는 처리할 데이터를 돌려주는 공통 read interface를 구현해야 한다.
비슷하게, 위와 비슷한 interface를 필요로 하는 MapReduce 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의 구체적인 subclass이다.
class LineCountWorker(Worker):
def map(self):
data = self.input_data.read()
self.result = data.count('\n')
def reduce(self, other):
self.result += other.result
위 코드는 잘 작동할 것처럼 보이지만, 각 부분들을 어떻게 연결해야 하는지에서 큰 문제가 생긴다.
가장 간단한 접근 방법은 helper function을 이용해 object를 만들고, 연결하는 것이다.
다음 코드는 directory의 list를 얻어서, 그 안에 있는 파일마다 PathInputData 인스턴스를 만든다.
import os
def generate_inputs(data_dir):
for name in os.listdir(data_dir):
yield PathInputData(os.path.join(data_dir, name))
그 후, generate_inputs를 통해 만든 InputData 인스턴스들을 이용하는 LineCountWorker 인스턴스를 만든다.
def create_workers(input_list):
workers = []
for input_data in input_list:
workers.append(LineCountWorker(input_data))
return workers
이 Worker 인스턴스의 map step을 여러 thread에 fanning out하여 실행할 수 있다. 그 후, reduce를 반복적으로 호출해 결과를 최종 값으로 합친다.
from 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
마지막으로, 지금까지 만든 모든 piece들을 한 함수 안에 합친다.
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'There are {result} lines')
>>>
There are 4360 lines
문제가 뭘까? 앞서 정의한 mapreduce 함수의 문제는, 이 함수가 전혀 generic하지 않다는 것이다. 즉, 다른 InputData나 Worker subclass를 사용하려면 해당 subclass에 맞게 다 재작성 해야한다.
이 문제를 해결하기 위해서는 class method 다형성을 사용하는 것이다. 이 방식은 InputData.read에서 사용했던 instance method의 다형성과 똑같지만, 클래스로 만들어낸 개별 object에 적용되는 것이 아니라 class 전체에 적용된다는 게 차이점이다.
class GenericInputData:
def read(self):
raise NotImplementedError
@classmethod
def generate_inputs(cls, config):
raise NotImplementedError
위처럼 @classmethod가 적용된 클래스 메소드는 공통의 interface를 통해 새로운 InputData 인스턴스를 생성한다.
generate_inputs는 GenericInputData의 구체적인 subclass가 객체를 생성하는 방법을 알려주는 설정 정보가 들어있는 dictionary를 parameter로 받는다. 다음 코드는 입력 파일이 들어 있는 directory를 찾기 위해 이 config 를 사용한다.
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 라는 helper method를 추가할 수 있고, 이는 GenericInputData의 하위 타입인 input_class를 파라미터로 받는다. input_class는 필요한 입력을 생성해주고, GenericWorker의 구체적인 하위 타입의 instance를 만들 때는 cls()를 generic 생성자로 사용한다.
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_class, config):
workers = []
for input_data in input_class.generate_inputs(config):
workers.append(cls(input_data))
return workers
위의 input_class.generate_input 호출이 바로 클래스 다형성의 예이다.
위처럼 변경하면, GenericWorker subclass에 미치는 영향을 부모 클래스를 바꾸는 것 뿐이다.
class LineCountWorker(GenericWorker):
...
마지막으로, mapreduce 함수가 create_workers를 호출하게 변경해서 mapreduce를 완전한 generic 함수로 만들 수 있다.
def mapreduce(worker_class, input_class, config):
workers = worker_class.create_workers(input_class, config)
return execute(workers)
같은 테스트 파일들에 대해 위의 코드를 적용하면 이전 구현과 같은 결과를 얻는다. 단, generic 해야 하므로 mapreduce 함수에 더 많은 parameter를 넘겨야한다.
config = {'data_dir': tmpdir}
result = mapreduce(LineCountWorker, PathInputData, config)
print(f'There are {result} lines')
>>>
There are 4360 lines
하지만 이젠 각 subclass의 instance 객체를 결합하는 코드의 변경 없이도 GnericInputData와 GenericWorker의 subclass를 내가 원하는대로 작성 가능하다.
Item 40: super로 부모 클래스를 초기화하라
이전 파이썬 버전은 아래처럼 부모 클래스의 __init__ 메소드를 직접 child instance에서 호출하는 것이었다.
class MyBaseClass:
def __init__(self, value):
self.value = value
class MyChildClass(MyBaseClass):
def __init__(self):
MyBaseClass.__init__(self, 5)
위와 같은 방법은 기본적인 class 계층의 경우에는 잘 동작하나, 문제가 생길 수도 있다.
다중 상속이 그 예이다. 다중 상속을 하게 되면, 어떤 하위 클래스에서 __init__을 먼저 호출할 지 순서가 정해져있지 않다는 것이다.
class TimesTwo:
def __init__(self):
self.value *= 2
class PlusFive:
def __init__(self):
self.value += 5
class OneWay(MyBaseClass, TimesTwo, PlusFive):
def __init__(self, value):
MyBaseClass.__init__(self, value)
TimesTwo.__init__(self)
PlusFive.__init__(self)
위에서 OneWay 클래스는 부모 클래스로 TimesTwo, PlusFive 순으로 정의한다.
이 클래스의 instance를 만들면 부모 클래스 순서에 따라 초기화가 이루어진다.
foo = OneWay(5)
print('First ordering value is (5 * 2) + 5 =', foo.value)
>>>
First ordering value is (5 * 2) + 5 = 15
반대로 PlusFive, TimesTwo 순으로 부모 클래스를 나열해보자.
class AnotherWay(MyBaseClass, PlusFive, TimesTwo):
def __init__(self, value):
MyBaseClass.__init__(self, value)
TimesTwo.__init__(self)
PlusFive.__init__(self)
bar = AnotherWay(5)
print('Second ordering value is', bar.value)
>>>
Second ordering value is 15
하지만, 부모 클래스의 생성자(__init__)를 호출하는 순서는 그대로이기 때문에 instance를 만들면 앞선 예와 똑같은 결과를 얻게 된다. 즉, 클래스 정의에서 부모 클래스 나열 순서와 생성자 호출 순서가 일치하지 않는다는 뜻이다. 즉, 이런 경우 문제 발견도 어렵고, 이해하기도 여러울 수 있다.
다이아몬드 상속(Diamond inheritance, 어떤 클래스가 두 가지 서로 다른 클래스를 상속하는데 두 클래스가 거슬러 올라가면 같은 조상 클래스가 존재할 때)도 문제가 될 수 있다. 그렇게 되면 공통의 조상 클래스의 __init__ 메소드가 여러 번 호출될 수 있으므로 코드의 원치 않은 동작을 유발할 수 있다.
class TimesSeven(MyBaseClass):
def __init__(self, value):
MyBaseClass.__init__(self, value)
self.value *= 7
class PlusNine(MyBaseClass):
def __init__(self, value):
MyBaseClass.__init__(self, value)
self.value += 9
class ThisWay(TimesSeven, PlusNine):
def __init__(self, value):
TimesSeven.__init__(self, value)
PlusNine.__init__(self, value)
foo = ThisWay(5)
print('Should be (5 * 7) + 9 = 44 but is', foo.value)
>>>
Should be (5 * 7) + 9 = 44 but is 14
위와 같은 오류가 나는 이유는, 두 번째 부모 클래스의 PlusNine.__init__ 이 다시 호출되면서 self.value가 5로 돌아가고, 이로 인해 self.value를 계산한 값이 5+9 = 14가 되며, TimeSeven.__init__ 에서 수행한 계산은 완전히 무시가 된다.
이러한 문제를 해결하기 위해 파이썬에는 super라는 내장 함수와 표준 메서드 결정 순서(Method Resolution Order, MRO)가 있다. 이는 다이아몬드 계층에서 공통 상위 클래스를 단 한번 호출하도록 보장하며, 상위 클래스의 초기화 순서도 정의한다. 이때, C3 linearization이라는 알고리즘을 사용한다.
class TimesSevenCorrect(MyBaseClass):
def __init__(self, value):
super().__init__(value)
self.value *= 7
class PlusNineCorrect(MyBaseClass):
def __init__(self, value):
super().__init__(value)
self.value += 9
class GoodWay(TimesSevenCorrect, PlusNineCorrect):
def __init__(self, value):
super().__init__(value)
foo = GoodWay(5)
print('Should be 7 * (5 + 9) = 98 and is', foo.value)
>>>
Should be 7 * (5 + 9) = 98 and is 98
이제는 정상적으로 동작한다. 그런데, TimesSevenCorrect가 먼저 호출되어야 하는 게 아닌가 싶을 수도 있다.
호출 순서는 이 클래스에 대한 MRO 정의를 따른다. 아래를 보자.
mro_str = '\n'.join(repr(cls) for cls in GoodWay.mro())
print(mro_str)
>>>
<class '__main__.GoodWay'>
<class '__main__.TimesSevenCorrect'>
<class '__main__.PlusNineCorrect'>
<class '__main__.MyBaseClass'>
<class 'object'>
여기서 GoodWay(5)를 호출하면, 차례로 TimesSevenCorrect.__init__을 호출하고, PlusNineCorrect.__init__을 호출하고 그 다음 MyBaseClass.__init__을 호출한다. 다이아몬드의 top에 다다르면, 각 초기화 method는 각 클래스의 __init__이 호출된 역순으로 수행된다. 즉, MyBaseClass.__init__은 value에 5를 대입하고, PlusNineCorrect.__init__은 value에 9를 더해서 14로 만들고, TimesSevenCorrect.__init__은 value에 7을 곱해서 98로 만든다.
이처럼 super().__init__은 다중 상속을 안전하게 해주며, 유지보수를 더 용이하게 한다.
또한, super 함수에 두 가지 파라미터를 넘길 수 있는데, 첫 번째는 우리가 접근하고 싶은 MRO 뷰를 제공할 부모 타입이고, 두 번째는 첫 번째 파라미터로 지정한 타입의 MRO 뷰에 접근할 때 사용할 instace이다.
class ExplicitTrisect(MyBaseClass):
def __init__(self, value):
super(ExplicitTrisect, self).__init__(value)
self.value /= 3
하지만, object instance를 초기화할 때는 두 파라미터를 지정할 필요가 없다. 컴파일러가 알아서 올바른 parameter(__class__와 self)를 넣어주기 때문이다. 따라서 아래 두 가지 모두 위의 ExplicitTrisect와 동일하다.
class AutomaticTrisect(MyBaseClass):
def __init__(self, value):
super(__class__, self).__init__(value)
self.value /= 3
class ImplicitTrisect(MyBaseClass):
def __init__(self, value):
super().__init__(value)
self.value /= 3
assert ExplicitTrisect(9).value == 3
assert AutomaticTrisect(9).value == 3
assert ImplicitTrisect(9).value == 3
super에 파라미터를 제공해야 하는 유일한 경우는 자식 클래스에서 상위 클래스의 특정 기능에 접근해야 하는 경우뿐이다.
Item 41: 기능 합성에는 Mix-in class를 사용하라
다중 상속으로 인한 문제를 피하고 싶다면, mix-in을 고려해보자.
mix-in은 자식 클래스가 사용할 메소드 몇 개만을 정의하는 클래스다. mix-in 클래스에는 자체 attribute 정의가 없으므로 __init__ 메소드를 호출할 필요도 없다.
믹스인을 합성하거나 계층화해서 반복적인 코드를 최소화하고 재사용성을 최대화할 수 있다.
예를 들어, 메모리 내에 들어있는 파이썬 객체를 serialization에 사용할 수 있도록 딕셔너리로 바꾸고 싶다. 이 기능을 generic하게 작성해서 여러 클래스에 활용하면 좋을 것 같다. 다음은 그러한 믹스인 예제이고, 이 믹스인을 상속하는 모든 클래스에서 이 함수의 기능을 사용할 수 있다.
class ToDictMixin:
def to_dict(self):
return self._traverse_dict(self.__dict__)
def _traverse_dict(self, instance_dict):
output = {}
for key, value in instance_dict.items():
output[key] = self._traverse(key, value)
return output
def _traverse(self, key, value):
if isinstance(value, ToDictMixin):
return value.to_dict()
elif isinstance(value, dict):
return self._traverse_dict(value)
elif isinstance(value, list):
return [self._traverse(key, i) for i in value]
elif hasattr(value, '__dict__'):
return self._traverse_dict(value.__dict__)
else:
return value
위 _traverse_dice 메소드는 hasattr를 통한 동적인 attribute 접근과 isinstance를 사용한 타입 검사, __dict__를 통한 인스턴스 딕셔너리 접근을 활용해서 간단하게 구현 가능하다.
다음은 이 믹스인을 사용해 이진 트리를 딕셔너리 표현으로 변경하는 예이다.
class BinaryTree(ToDictMixin):
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
tree = BinaryTree(10,
left=BinaryTree(7, right=BinaryTree(9)),
right=BinaryTree(13, left=BinaryTree(11)))
print(tree.to_dict())
>>>
{'value': 10,
'left': {'value': 7,
'left': None,
'right': {'value': 9, 'left': None, 'right': None}},
'right': {'value': 13,
'left': {'value': 11, 'left': None, 'right': None},
'right': None}}
믹스인의 가장 큰 장점은 generic 기능을 쉽게 연결할 수 있고, 필요할 때 기존 기능을 다른 기능으로 override할 수 있다는 것이다. 예를 들어, 아래 코드는 BinaryTree에 대한 참조를 저장하는 BinaryTree의 subclass를 정의한다. 이런 circular reference는 원래의 ToDictMixin.to_dict 구현은 무한 루프에 빠지게 된다.
class BinaryTreeWithParent(BinaryTree):
def __init__(self, value, left=None, right=None, parent=None):
super().__init__(value, left=left, right=right)
self.parent = parent
해결 방법은 BinaryTreeWithParent._traverse 메소드를 override해서 문제가 되는 값만 처리하면 된다. 다음 코드에서는 _traverse를 override한 메소드는 부모를 가리키는 참조에 대해서는 부모의 숫자 값을 대입하고, 아닌 경우에는 super를 통해 default 믹스인을 호출한다.
class BinaryTreeWithParent(BinaryTree):
def __init__(self, value, left=None, right=None, parent=None):
super().__init__(value, left=left, right=right)
self.parent = parent
def _traverse(self, key, value):
if (isinstance(value, BinaryTreeWithParent) and key == 'parent'):
return value.value # Prevent cycles
else:
return super()._traverse(key, value)
위와 같이 하면 변환 시에 circular reference를 따라가지 않으므로 BinaryTreeWithParent.to_dict가 잘 동작한다.
root = BinaryTreeWithParent(10)
root.left = BinaryTreeWithParent(7, parent=root)
root.left.right = BinaryTreeWithParent(9, parent=root.left)
print(root.to_dict())
>>>
{'value': 10,
'left': {'value': 7,
'left': None,
'right': {'value': 9, 'left': None, 'right': None, 'parent': 7},
'parent': 10},
'right': None,
'parent': None}
BinaryTreeWithParent._traverse를 override함으로써 BinaryTreeWithParent를 attribute로 저장하는 모든 클래스도 자동으로 ToDictMixin을 문제없이 쓸 수 있게 된다.
class NamedSubTree(ToDictMixin):
def __init__(self, name, tree_with_parent):
self.name = name
self.tree_with_parent = tree_with_parent
my_tree = NamedSubTree('foobar', root.left.right)
print(my_tree.to_dict()) # No infinite loop
>>>
{'name': 'foobar',
'tree_with_parent': {'value': 9,
'left': None,
'right': None,
'parent': 7}}
뿐만 아니라 mix-in을 서로 합성도 가능하다. 예를 들어, 임의의 class에 대해 generic JSON serialization을 제공하는 mix-in을 원한다면, class가 to_dict 메소드를 제공한다고 가정하면 된다.
import json
class JsonMixin:
@classmethod
def from_json(cls, data):
kwargs = json.loads(data)
return cls(**kwargs)
def to_json(self):
return json.dumps(self.to_dict())
위에서 JsonMixin 클래스 안에 instance 메소드와 class 메소드가 함께 정의가 됐다는 것이 중요하다. 믹스인을 사용하면 둘 중 어느 것이든 subclass에 추가할 수 있다. 위의 예제에서 JsonMixin 의 subclass의 요구 사항은 to_dict 메소드를 제공해야 하는 것과, __init__ 메소드가 키워드 인자를 받아야 한다는 것이다.
이렇듯 mix-in이 있으면 JSON과 양방향으로 serialization을 할 utility class의 계층 구조를 쉽게 만들 수 있다.
예를 들어, 데이터 센터의 각 요소 간 연결(topology)을 표현하는 클래스 계층을 생각해보자.
class DatacenterRack(ToDictMixin, JsonMixin):
def __init__(self, switch=None, machines=None):
self.switch = Switch(**switch)
self.machines = [Machine(**kwargs) for kwargs in machines]
class Switch(ToDictMixin, JsonMixin):
def __init__(self, ports=None, speed=None):
self.ports = ports
self.speed = speed
class Machine(ToDictMixin, JsonMixin):
def __init__(self, cores=None, ram=None, disk=None):
self.cores = cores
self.ram = ram
self.disk = disk
다음은 양방향 serialization이 가능한지 검사하는 코드이다.
serialized = """{
"switch": {"ports": 5, "speed": 1e9},
"machines": [
{"cores": 8, "ram": 32e9, "disk": 5e12},
{"cores": 4, "ram": 16e9, "disk": 1e12},
{"cores": 2, "ram": 4e9, "disk": 500e9}
]
}"""
deserialized = DatacenterRack.from_json(serialized)
roundtrip = deserialized.to_json()
assert json.loads(serialized) == json.loads(roundtrip)
위처럼 JsonMixin을 적용하려 하는 클래스 상속 계층의 상위 클래스에 이미 JsonMixin을 적용한 클래스가 있어도 아무런 문제가 없다.
Item 42: Private attribute보다는 public attribute를 선호하라
Python에서 클래스의 attribute의 visibility는 public 과 private 밖에 없다.
class MyObject:
def __init__(self):
self.public_field = 5
self.__private_field = 10
def get_private_field(self):
return self.__private_field
객체 뒤에 점 연산자(.)을 붙이면 public attribute에 접근할 수 있다.
foo = MyObject()
assert foo.public_field == 5
attribute의 이름 앞에 underscore를 2개 붙이면 (__) private attribute가 된다. Private attribute 를 포함하는 클래스 안의 method에서는 해당 attribute에 직접 접근 가능하다.
assert foo.get_private_field() == 10
하지만 클래스 외부에서 private attribute 에 접근하면 예외가 발생한다.
foo.__private_field
>>>
Traceback ...
AttributeError: 'MyObject' object has no attribute
➥'__private_field'
Class method는 자신을 둘러싸고 있는 class 블럭 내부에 있으므로 해당 클래스의 private attribute에 접근 가능하다.
class MyOtherObject:
def __init__(self):
self.__private_field = 71
@classmethod
def get_private_field_of_instance(cls, instance):
return instance.__private_field
bar = MyOtherObject()
assert MyOtherObject.get_private_field_of_instance(bar) == 71
또, subclass는 부모 클래스의 private attribute에 접근할 수 없다.
class MyParentObject:
def __init__(self):
self.__private_field = 71
class MyChildObject(MyParentObject):
def get_private_field(self):
return self.__private_field
baz = MyChildObject()
baz.get_private_field()
>>>
Traceback ...
AttributeError: 'MyChildObject' object has no attribute
➥'_MyChildObject__private_field'
Private attribute의 동작은 attribute의 이름을 바꾸는 간단한 방식으로 구현된다. MyChildObject.get_private_field처럼 메소드 내부에서 private attribute에 접근하는 코드가 있으면, 컴파일러는 __private_field라는 attribute 접근 코드를 _MyChildObject__private_field라는 이름으로 바꿔준다. 위의 예제에서는 MyParentObject.__init__에만 __private_field 정의가 있으므로, 이 attribute의 실제 이름은 _MyParentObject__private_field라는 뜻이다. 부모의 private attribute를 자식 attribute에서 접근하면, 단지 변경한 attribute의 이름이 존재하지 않는다는 이유로 오류가 발생한다.(_MyParentObject__private_field가 아니라 MyChildObject__private_field로 이름이 바뀜)
위의 동작을 알고 있다면, 특별한 권한 요청 없이 손쉽게 어디서든 원하는 클래스의 private attribute에 접근 가능하다.
assert baz._MyParentObject__private_field == 71
Object의 attribute dictionary를 보면, 실제로 변환된 private attribute 이름이 들어있는 모습을 볼 수 있다.
print(baz.__dict__)
>>>
{'_MyParentObject__private_field': 71}
이를 보면, private attribute에 대한 접근 구문이 그리 visibility를 엄격하게 제한하지 않는다는 것을 알 수 있다. 파이썬 프로그래머들은 이렇게 접근을 열어둠으로써 얻을 수 있는 이익이 단점보다 더 크다고 믿었기 때문이다.
또한, 파이썬은 attribute에 접근할 수 있는 언어 기능에 대한 hook을 지원하므로, 원할 경우 객체의 내부를 마음대로 할 수 있다.
따라서 PEP에 따른 명명 규약을 지키는 것이 중요하다.
필드 앞에 밑줄이 하나면(_protected_field) 관례적으로 protected field를 의미한다. Protected field는 클래스 외부에서 이 필드를 사용할 때 조심해야 한다는 것을 의미한다.
하지만 파이썬을 사용하는 많은 프로그래머가 하위 클래스나 클래스 외부에서 사용하면 안 되는 내부 API를 표현하기 위해 private field를 쓴다.
class MyStringClass:
def __init__(self, value):
self.__value = value
def get_value(self):
return str(self.__value)
foo = MyStringClass(5)
assert foo.get_value() == '5'
위와 같은 접근은 잘못된 방법이다. 누군가 이 클래스를 상속하면서 새로운 기능을 추가하거나, 기존 메소드의 단점을 해결하기 위해 어떤 동작을 추가하고 싶을 수 있는데, 이렇게 private attribute를 사용하면 확장성을 낮추고 subclass의 overrider를 귀찮게 한다.
꼭 접근해야 한다면 위에서 언급한 것처럼 아래 코드와 같이 접근을 할 수는 있다.
class MyIntegerSubclass(MyStringClass):
def get_value(self):
return int(self._MyStringClass__value)
foo = MyIntegerSubclass('5')
assert foo.get_value() == 5
하지만 그렇게 바람직한 방법은 아님을 알 수 있다.
하지만 MyStringClass의 정의를 변경하면 더 이상 참조가 바르지 않기 때문에 subclass가 깨지게 된다.
class MyBaseClass:
def __init__(self, value):
self.__value = value
def get_value(self):
return self.__value
class MyStringClass(MyBaseClass):
def get_value(self):
return str(super().get_value()) # Updated
class MyIntegerSubclass(MyStringClass):
def get_value(self):
return int(self._MyStringClass__value) # Not updated
위는 MyStringClass에 새로운 부모 클래스인 MyBaseClass를 추가한 코드이다.
이렇게 되면 MyIntegerSubclass 의 private field에 대한 참조인 self._MyStringClass__value가 깨지게 된다.
foo = MyIntegerSubclass(5)
foo.get_value()
>>>
Traceback ...
AttributeError: 'MyIntegerSubclass' object has no attribute
➥'_MyStringClass__value'
일반적으로, 부모 클래스쪽에서 protected attribute를 사용하고 error를 내는 것이 더 낫다. 따라서 모든 protected field에 document를 달고, API 내부의 어떤 필드를 하위 클래스에서 변경할 수 있고 어떤 필드를 그대로 둬야하는지 명시할 것을 필자는 권장한다.
class MyStringClass:
def __init__(self, value):
# This stores the user-supplied value for the object.
# It should be coercible to a string. Once assigned in
# the object it should be treated as immutable.
self._value = value
...
Private attribute를 사용할 지 정말 고민해야 하는 유일한 경우는 subclass의 필드와 이름이 충돌할 수 있는 경우뿐이다.
자식 클래스가 실수로 부모 클래스가 이미 정의한 attribute를 정의하면 문제가 생길 수 있다.
class ApiClass:
def __init__(self):
self._value = 5
def get(self):
return self._value
class Child(ApiClass):
def __init__(self):
super().__init__()
self._value = 'hello' # Conflicts
a = Child()
print(f'{a.get()} and {a._value} should be different')
>>>
hello and hello should be different
주로 공개 API에 속한 클래스의 경우 신경 써야 하는 부분이다.
이런 문제 발생을 최대한 줄이려면, 부모 클래스 쪽에서 자식 클래스의 attribute 이름이 자신의 attribute 이름과 겹치는 일을 방지하기 위해 private attribute를 쓸 수 있다.
class ApiClass:
def __init__(self):
self.__value = 5 # Double underscore
def get(self):
return self.__value # Double underscore
class Child(ApiClass):
def __init__(self):
super().__init__()
self._value = 'hello' # OK!
a = Child()
print(f'{a.get()} and {a._value} are different')
>>>
5 and hello are different
Item 43: Custom container type에는 collections.abc를 상속하라
모든 파이썬 클래스는 함수와 attribute를 함께 encapsulation하는 일종의 container라고 할 수 있다.
Sequence처럼 간단한 클래스를 정의할 때는 파이썬의 내장 list type의 subclass를 만들고 싶은 것이 당연하다.
예를 들어, 멤버들의 member의 frequency를 count하는 method가 포함된 커스텀 리스트 타입이 필요하다고 가정하자.
class FrequencyList(list):
def __init__(self, members):
super().__init__(members)
def frequency(self):
counts = {}
for item in self:
counts[item] = counts.get(item, 0) + 1
return counts
foo = FrequencyList(['a', 'b', 'a', 'c', 'b', 'a', 'd'])
print('Length is', len(foo))
foo.pop()
print('After pop:', repr(foo))
print('Frequency:', foo.frequency())
>>>
Length is 7
After pop: ['a', 'b', 'a', 'c', 'b', 'a']
Frequency: {'a': 3, 'b': 2, 'c': 1}
위처럼 FrequencyList를 list의 subclass로 만듦으로써 list가 제공하는 모든 standard functionality를 FrequencyList에서도 사용할 수 있다. 뿐만 아니라 필요한 기능을 제공하는 method를 추가할 수도 있다.
만약, list처럼 느껴지면서 indexing이 가능한 객체를 원하기는 한데, list의 subclass로 만들고싶지는 않다고 가정해보자. 예를 들어, 아래 이진 트리 클래스를 sequence의 semenatics를 사용해 다룰 수 있는 클래스를 만들어보자.
class BinaryNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
위의 클래스가 sequence type처럼 작동하게 하려면 어떻게 해야할까? 파이썬에서는 특별한 이름의 instance method를 사용해 컨테이너의 동작을 구현한다. 인덱스를 사용해
bar = [1, 2, 3]
bar[0]
위와 같이 sequence에 접근하는 코드는
bar.__getitem__(0)
과 같은 special method로 해석된다.
BinaryNode 클래스가 sequence처럼 동작하게하려면 트리 노드를 depth first traverse 하는 custom __getitem__ 메서드 구현을 제공해야한다.
class IndexableNode(BinaryNode):
def _traverse(self):
if self.left is not None:
yield from self.left._traverse()
yield self
if self.right is not None:
yield from self.right._traverse()
def __getitem__(self, index):
for i, item in enumerate(self._traverse()):
if i == index:
return item.value
raise IndexError(f'Index {index} is out of range')
이진 트리는 평소처럼 생성하면 된다.
tree = IndexableNode(
10,
left=IndexableNode(
5,
left=IndexableNode(2),
right=IndexableNode(
6,
right=IndexableNode(7))),
right=IndexableNode(
15,
left=IndexableNode(11)))
위의 트리를 left나 right attribute를 이용해 순회할 수도 있지만, 추가로 list처럼 접근을 할 수도 있다.
print('LRR is', tree.left.right.right.value)
print('Index 0 is', tree[0])
print('Index 1 is', tree[1])
print('11 in the tree?', 11 in tree)
print('17 in the tree?', 17 in tree)
print('Tree is', list(tree))
>>>
LRR is 7
Index 0 is 2
Index 1 is 5
11 in the tree? True
17 in the tree? False
Tree is [2, 5, 6, 7, 10, 11, 15]
하지만 __getitem__을 구현하는 것만으로는 list instance에서 기대할 수 있는 모든 sequence semantics를 제공할 수는 없다.
len(tree)
>>>
Traceback ...
TypeError: object of type 'IndexableNode' has no len()
len 내장 함수는 __len__이라는 이름의 special method를 구현해야 제대로 동작한다. Custom sequence type은 이 method를 반드시 구현해야 한다.
class SequenceNode(IndexableNode):
def __len__(self):
for count, _ in enumerate(self._traverse(), 1):
pass
return count
tree = SequenceNode(
10,
left=SequenceNode(
5,
left=SequenceNode(2),
right=SequenceNode(
6,
right=SequenceNode(7))),
right=SequenceNode(
15,
left=SequenceNode(11))
)
print('Tree length is', len(tree))
>>>
Tree length is 7
하지만 __getitem__과 __len__을 구현한다고 어떤 클래스가 올바른 sequence가 되는 것은 아니다.(count나 index도 아직 구현이 안됨)
따라서 custom container type을 직접 정의하는 것은 매우 어려운 일임을 알 수 있다.
이런 어려움을 덜어주기 위해 내장 collections.abc 모듈에 container type에 정의해야 하는 전형적인 method를 모두 제공하는 추상 기반 클래스 정의(abstract base class)가 여러 가지 들어 있다. 따라서 이런 추상 기반 클래스의 subclass를 만들고 필요한 메소드 구현을 빼먹으면, collections.abc 모듈이 실수한 부분을 알려준다.
from collections.abc import Sequence
class BadType(Sequence):
pass
foo = BadType()
>>>
Traceback ...
TypeError: Can't instantiate abstract class BadType with
➥abstract methods __getitem__, __len__
SequenceNode에서 한 것처럼 collections.abc에서 가져온 abstract base class가 요구하는 모든 method를 구현하면, index나 count와 같은 추가 메소드 구현을 거저 얻을 수 있다.
class BetterNode(SequenceNode, Sequence):
pass
tree = BetterNode(
10,
left=BetterNode(
5,
left=BetterNode(2),
right=BetterNode(
6,
right=BetterNode(7))),
right=BetterNode(
15,
left=BetterNode(11))
)
print('Index of 7 is', tree.index(7))
print('Count of 10 is', tree.count(10))
>>>
Index of 7 is 3
Count of 10 is 1
Set과 MutableMapping처럼 special method가 훨씬 많은 복잡한 container type을 구현할 때는 더더욱 이 모듈을 사용하는 게 좋다.
/ /문제제기 및 피드백 언제든지 감사히 받겠습니다.
'Computer Science > Effective Python' 카테고리의 다른 글
Chapter 4. Comprehensions and Generators (1) | 2023.01.11 |
---|---|
Chapter 3. Functions (0) | 2023.01.07 |
Chapter 2. Lists and Dictionaries (0) | 2023.01.06 |
Chapter 1. Pythonic Thinking (0) | 2023.01.03 |