파이썬 코딩의 기술 - 37

내장 타입을 여러 단계로 내포시키기보다는 클래스를 합성하라.

파이썬 내장 딕셔너리 타입을 사용하면 객체의 생명 주기 동안 동적인 내부 상태를 잘 유지할 수 있다. 여기서 동적(dynamic) 이라는 말은 어떤 값이 들어올지 미리 알 수 없는 식별자들을 유지해야 한다는 뜻이다.

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('아이작 뉴턴')
book.report_grade('아이작 뉴턴', 90)
book.report_grade('아이작 뉴턴', 95)
book.report_grade('아이작 뉴턴', 85)

print(book.average_grade('아이작 뉴턴'))
   
>>>
90.0

딕셔너리 관련 내장 타입은 사용하기 너무 쉬우므로 과하게 확장하면서 깨지기 쉬운 코드를 작성할 위험성이 있다. 예를 들어 SimpleGradebook 클래스를 확장해서 전체 성적이 아니라 과목별 성적을 리스트로 저장하고 싶다고 하자.

from collections import defaultdict

class BySubjectGradebook:
    def __init__(self):
        self._grades = {} # 외부 dict

    def add_student(self, name):
        self._grades[name] = defaultdict(list) # 내부 dict

다단계 딕셔너리를 처리해야 하므로 report_gradeaverage_grade 메서드가 많이 복잡해지지만 이 코드는 아주 평이하다.

    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('알버트 아인슈타인')
book.report_grade('알버트 아인슈타인', '수학', 75)
book.report_grade('알버트 아인슈타인', '수학', 65)
book.report_grade('알버트 아인슈타인', '체육', 90)
book.add_student('알버트 아인슈타인', '체육', 95)

print(book.average_grade('알버트 아인슈타인'))
        
>>>
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, 0
        
            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('알버트 아인슈타인')
book.report_grade('알버트 아인슈타인', '수학', 75, 0.05)
book.report_grade('알버트 아인슈타인', '수학', 65, 0.15)
book.report_grade('알버트 아인슈타인', '수학', 70, 0.85)
book.add_student('알버트 아인슈타인', '체육', 100, 0.40)
book.add_student('알버트 아인슈타인', '체육', 85, 0.60)

print(book.average_grade('알버트 아인슈타인'))

>>>
80.25

report_grade에는 단순한 변경만 일어났지만, average_grade 메서드에서는 루프 안에 루프가 쓰이면서 읽기 어려워졌다. 또한 클래스도 쓰기 어려워졌다.

이와 같은 복잡도가 눈에 들어오면 더 이상 딕셔너리, 튜플, 집합, 리스트 등의 내장 타입을 사용하지 말고 클래스 계층 구조를 사용해야 한다. 이를 통ㅎ해 데이터를 더 잘 캡슐화 해주는 잘 정의된 인터페이스를 제공할 수 있다. 이런 접근 방법을 택하면 인터페이스와 구체적인 구현 사이에 잘 정의된 추상화 계층을 만들 수도 있다.

클래스를 활용해 리팩토링하기

리팩토링할 때 취할 수 있는 접근 방법은 많다. 여기서는 먼저 의존 관계 트리의 맨 밑바닥 (이 예제에서는 점수와 가중치의 튜플)을 점수로 표현하는 클래스로 옮겨갈 수 있다. 하지만 이런 단순한 정보, 불변 값은 튜플이 더 적당해 보인다.

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

이 코드의 문제점은 튜플에 저장된 내부 왼소에 위치를 사용해 접근한다는 것이다. 만약 새로운 정보가 늘어난다면 기존에 원소가 두 개인 튜플을 처리하던 코드 각 부분을 모두 원소가 세 개인 튜플로 처리하도록 바꿔야 한다.

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

이런 식으로 원소가 세 개 이상인 튜플을 사용한다면 collection 내장 모듈에 있는 namedtuple 타입을 사용해 작은 불변 데이터 클래스를 쉽게 정의할 수 있다.

from collections import namedtuple

Grade = namedtuple('Grade', ('score', 'weight'))

이 클래스의 인스턴스를 만들 때는 위치 기반 인자를 사용해도 되고 키워드 인자를 사용해도 된다. 필드에 접근할 때는 애트리뷰트 이름을 쓸 수 있다.

namedtuple의 한계

  • namedtuple 클래스에는 디폴트 인자 값을 지정할 수 없다. 따라서 선택적인 프로퍼티가 많은 데이터에 사용하기는 어렵다. 프로퍼티가 4~5개보다 더 많아지면 dataclasses 내장 모듈을 사용하는 편이 낫다.
  • namedtuple은 인덱스를 사용해 접근할 수 있고, 이터레이션도 가능하다. 때문에 나중에 namedtuple을 실제 클래스로 변경하기 어려울 수 있다. namedtuple을 사용하는 모든 부분을 제어할 수 있는 상황이 아니라면 명시적으로 새로운 클래스를 정의하는 편이 낫다.
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
class Gradebook: # 모든 학생을 저장하는 컨테이너
    def __init__(self):
        self._students = defaultdict(Student)
        
   def get_student(self, name):
       return self._students[name]

book = Gradebook()
albert = book.get_student('알버트 아인슈타인')
math = albert.get_subject('수학')
math.report_grade(75, 0.05)
math.report_grade(65, 0.15)
math.report_grade(70, 0.80)

gym = albert.get_subject('체육')
gym.report_grade(100, 0.40)
gym.report_grade(85, 0.60)

print(albert.average_grade())

>>>
80.25

이전 보다 코드의 양은 두 배 이상 늘어났지만 일기가 쉽고 확장성이 좋아졌다. 또한 하위 호환성을 제공하는 메서드를 작성해 예전 스타일의 API를 사용 중인 코드를 새로운 객체 계층을 사용하는 코드로 쉽게 마이그레이션할 수도 있을 것이다.

기억해야 할 내용

  • 딕셔너리, 긴 튜플, 다른 내장 타입이 복잡하게 내포된 데이터를 값으로 사용하는 딕셔너리를 만들지 말라.
  • 완전한 클래스가 제공하는 유연성이 필요하지 않고 가벼운 불변 데이터 컨테이너가 필요하다면 namedtuple을 사용하라.
  • 내부 상태를 표현하는 딕셔너리가 복잡해지면 이 데이터를 관리하는 코드를 여러 클래스로 나눠서 재작성하라.

좋은 웹페이지 즐겨찾기