ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Python] Tip - 딕셔너리와 튜플보다는 헬퍼 클래스로 관리
    언어/파이썬 & 장고 2016. 10. 21. 21:40

    파이썬에 내장되어 있는 딕셔너리 타입은 객체의 수명이 지속되는 동안 동적인 내부 상태를 관리하는 용도로 좋습니다. 여기서 '동적'이란 예상하지 못한 식별자들을 관리해야 하는 상황을 뜻합니다. 예를 들어 이름을 모르는 학생 집단의 성적을 기록하고 싶다고 할 때, 학생 별로 미리 정의된 속성을 사용하지 않고 딕셔너리에 이름을 저장하는 클래스를 정의할 수 있습니다.

    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('newton')
    book.report_grade('newton', 90)
    
    print(book.average_grade('newton'))
    
    # 결과
    # 90 


    딕셔너리는 사용하기 쉬워서 과도하게 쓰다가 코드를 취약하게 작성할 위험이 있습니다. 예를 들어 SimpleGradebook 클래스를 확장해서 모든 성적을 한 곳에 저장하지 않고 과목별로 저장한다고 할 때 _grades 딕셔너리를 변경해서 학생 이름(키)을 또 다른 딕셔너리(값)에 매핑하면 됩니다. 가장 안쪽에 있는 딕셔너리는 과목(키)을 성적(값)에 매핑합니다.

    class BySubjectGradebook:
        def __init__(self):
            self._grades = {}
    
        def add_student(self, name):
            self._grades[name] = {}
    
        def report_grade(self, name, subject, grade):
            by_subject = self._grades[name]
            grade_list = by_subject.setdefault(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('newton')
    book.report_grade('newton', 'math', 90)
    book.report_grade('newton', 'english', 77)
    book.report_grade('newton', 'math', 70)
    book.report_grade('newton', 'english', 96)
    
    print(book.average_grade('newton'))
    
    # 결과
    # 83.25


    여기서 요구사항이 다시 바뀐다고 가정합니다. 수업의 최종 성적에서 각 점수가 차지하는 비중을 매겨서 중간고사와 기말고사를 쪽지시험보다 중요하게 만들려고 할 때, 바법 중 하나는 가장 안쪽 딕셔너리를 변경해 과목(키)를 성적(값)에 매핑하지 않고 성적과 비중을 담은 튜플 (score, weight)에 매핑하면 됩니다.

    class WeightedGradebook:
        def __init__(self):
            self._grades = {}
    
        def add_student(self, name):
            self._grades[name] = {}
    
        def report_grade(self, name, subject, score, weight):
            by_subject = self._grades[name]
            grade_list = by_subject.setdefault(subject, [])
            grade_list.append((score, weight))
    
        def average_grade(self, name):
            by_subject = self._grades[name]
            score_sum, score_count = 0, 0
    
            if __name__ == '__main__':
                for subject, scores in by_subject.items:
                    subject_avg, total_weight = 0, 0
                    for score, weight in scores:
                        pass
                        # ..
                
            return score_sum / score_count
    
    
    book = WeightedGradebook()
    book.add_student('newton')
    book.report_grade('newton', 'math', 90, 0.10)

    average_grade 메서드는 루프 안에 루프가 생겨서 이해하기 어려워 졌습니다. 또한 클래스를 사용하는 방법도 어려워져 위치 인수에 있는 숫자들이 무엇을 의미하는지도 명확하지 않습니다.


    이렇게 복잡해지면 딕셔너리와 튜플 대신 클래스의 계층 구조를 사용할 때입니다.

    딕셔너리를 담은 딕셔너리는 쓰지 않는 것이 좋습니다. 여러 계층으로 중첩하면 다른 프로그래머들이 코드를 이해하기 어려워지고 유지보수가 힘들어집니다. 관리하기 복잡하다고 느끼는 즉시 클래스로 옮겨가야 합니다. 그러면 데이터를 더 잘 캡슐화한 잘 정의된 인터페이스를 제공할 수 있고 인터페이스와 실제 구현사이에 추상화 계층을 만들 수 있습니다.

    클래스 리펙토링

    의존 관계에서 가장 아래에 있는 성적부터 클래스로 옮겨보겠습니다. 이렇게 간단한 정보를 담기에 클래스는 너무 무거워 보입니다. 성적은 변하지 않으니 튜플을 사용하는 게 더 적절해 보입니다. 다음 코드에서는 리스트 안의 성적을 기록하려고 (score, weight) 튜플을 사용합니다.

    grades = []
    grades.append((95, 0.45))
    # ...
    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, 'good'))
    # ...
    total = sum(score * weight for score, weight, _ in grades)
    total_weight = sum(weight for _, weight, _ in grades)
    average_grade = total / total_weight


    튜플을 점점 더 길게 확장하는 패턴은 딕셔너리의 계층을 깊게 두는 방식과 비슷합니다. 튜플의 아이템이 두 개를 넘어가면 다른 방법을 고려해야 합니다.

    collections 모듈의 namedtuple 타입이 정확하게 이런 요구에 부합합니다. namedtuple을 사용하면 작은 불변 데이터 클래스를 쉽게 정의할 수 있습니다.

    import collections
    Grade = collections.namedtuple('Grade', ('score', 'weight'))


    불볍 데이터 클래스는 위치 인수나 키워드 인수로 생성할 수 있습니다. 필드는 이름이 붙은 속성으로 접근할 수 있습니다. 이름이 붙은 속성이 있으면 나중에 요구 사항이 또 변해서 단순 데이터 컨테이너에 동작을 추가해야 할 떄 namedtuple에서 직접 작성한 클래스로 쉽게 바꿀 수 있습니다.

    namedtuple의 제약


    namedtuple이 여러 상황에서 유용하긴 하지만 장점보다 단점을 만들어 낼 수 있는 상황도 이해해야 합니다.

    • namedtuple로 만들 클래승에 기본 인수 값을 설정할 수 없습니다. 그래서 데이터에 선택적인 속성이 많으면 다루기 힘들어집니다. 속성을 사용할 때는 클래스에 직접 정의하는게 나을 수 있습니다.
    • namedtuple 인스턴스의 속성 값을 여전히 숫자로 된 인덱스와 순회 방법으로 접근할 수 있습니다. 특히 외부 API로 노출한 경우에는 의도와 다르게 사용되어 나중에 실제 클래스로 바꾸기 더 어려울 수도 있습니다. namedtuple 인스턴스를 사용하는 방식을 모두 제어할 수 없다면 클래스를 직접 정의하는 게 낫습니다.


    다음은 전부 클래스로 분리시킨 코드입니다. 코드라인은 이전에 구현한 코드보다 길지만 이해하기가 훨씬 쉽습니다.

    import collections
    
    Grade = collections.namedtuple('Grade', ('score', 'weight'))
    
    
    class Subject:
        """
        성적들을 담은 단일 과목을 표현하는 클래스
        """
        def __init__(self):
            self._grades = []
    
        def report_grade(self, score, weight):
            self._grades.append(Grade(score, weight)) # 값 [Grade(score=80, weight=0.1)]
    
        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._subject = {}
    
        def subject(self, name):
            if name not in self._subject:
                self._subject[name] = Subject() # 파이썬에서는 일급객체로서 클래스를 변수에 담을 수 있음
            return self._subject[name]
    
        def average_grade(self):
            total, count = 0, 0
            for subject in self._subject.values():
                total += subject.average_grade()
                count += 1
            return total / count
    
    
    class Gradebook:
        """
        학생의 이름을 키로 사용해 독적으로 모든 학생을 담을 컨테이너
        """
        def __init__(self):
            self._students = {}
    
        def student(self, name):
            if name not in self._students:
                self._students[name] = Student() # 파이썬에서는 일급객체로서 클래스를 변수에 담을 수 있음
            return self._students[name]
    
    
    book = Gradebook()
    albert = book.student('albert') # 딕셔너리에 albert란 key에 student()클래스를 담아서 리턴 받음
    math = albert.subject('math') # 딕셔너리 [albert]에다가 math란 key에 student()안의 subject() 클래스를 담아서 리턴받음
    # 위 까지 작업이 진행되었으면 총 dict구조로 따지면 다음과 같음
    # book['albert']['math']
    
    math.report_grade(80, 0.10) # subject()클래스 안의 report_grade()를 호출
    # 위 까지 작업이 진행되었으면 dict구조로 따지면 다음과 같음
    # book['albert']['math'][Grade(score=80, weight=0.1)]
    
    result = albert.average_grade() 
    # 먼저 student()클래스의 average_grade()를 호출 다음 학생 마다 for문을 돌며 subject()클래스의 average_grade() 함수 호출하여 과목 별 평균을 계산 
    print(result)
    
    # 결과
    # 80.0


    필요하면 이전 형태의 API 스타일로 작성한 코드를 새로 만든 객체 계층 스타일로 바꿔주는 하위 호환용 메서드를 작성해도 됩니다.

    요약

    다른 딕셔너리나 긴 튜플을 값으로 담은 딕셔너리를 생성하지 말기

    정식 클래스의 유연성이 필요 없다면 가벼운 불변 데이터 컨테이너에는 namedtuple을 사용

    내부 상태를 관리하는 딕셔너리가 복잡해지면 여러 헬퍼 클래스를 사용하는 방식으로 관리 코드를 변경


    댓글