6 분 소요

요즘 파이썬을 사용하면서 자연스레 좀 더 효율적으로 코드를 짜고 싶은 마음이 커졌습니다. 그래서 이 책을 공부하기 시작했습니다.
이 글은 개인 공부를 목적으로 작성되었습니다.
혹시 오타나 글의 수정사항이 있어 알려주시면 감사하겠습니다.




  • 객체지향 언어로서 파이썬은 상속(inheritance), 다형성(polymorphism), 캡슐화(encapsulation) 등과 같은 모든 기능을 제공한다.
  • 클래스와 상속을 사용하는 방법을 알아두면 유지 보수하기 쉬운 코드를 작성할 수 있다.


Better way 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._grades) # {'포은': [90, 95, 85]}
      print(book.average_grade('포은')) # 90.0
    


  • 파이썬 내장 딕셔너리와 튜플은 사용하기 편하므로 내부에 계속 딕셔너리, 리스트, 튜플 계층을 추가해가면서 코드를 사용하기 쉽다.
  • 하지만 내포 단계가 두 단계 이상이 되면 더 이상 딕셔너리, 리스트 튜플 계층을 추가하지 말아야 한다.
  • 코드에서 값을 관리하는 부분이 점점 복잡해지고 있음을 깨달은 즉시 해당 기능을 클래스로 분리해야 한다.


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

  • 다음 코드에서는 리스트 안에 점수를 저장하기 위해 튜플을 사용한다.
    • _ : 파이썬에서 사용하지 않는 변수 이름을 표시할 때 관례적으로 사용하는 밑줄
  • 코드

      grades = []
      grades.append((95, 0.45, 'A'))
      grades.append((85, 0.55, 'B'))
      total = sum(score * weight for score, weight, _ in grades)
      total_weight = sum(weight for _, weight, _ in grades)
      average_grade = total / total_weight # 89.5
    


  • 하지만 원소가 세 개 이상인 튜플을 사용한다면 다른 접근 방법을 생각해봐야 한다.
    • collection 내장 모듈에 있는 namedtuple을 사용하면 작은 불변 데이터 클래스를 쉽게 선택할 수 있다.
  • 코드

      from collections import namedtuple
    
      Grade = namedtuple('Grade', ('score', 'weight'))
    
  • 이 클래스의 인스턴스를 만들 때는 위치 기반 인자를 사용해도 되고 키워드 인자를 사용해도 된다.
  • 필드에 접근할 때는 애트리뷰트 이름을 쓸 수 있다.
  • 이름이 붙은 애트리뷰트를 사용할 수 있으므로 요구 사항이 바뀌는 경우에 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
    


  • 다음으로 한 학생이 수강하는 과목들을 표현하는 클래스를 작성할 수 있다.
  • 코드

      from collections import defaultdict
    
    
      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()
      poeun = book.get_student('포은')
      math = poeun.get_subject('수학')
      math.report_grade(75, 0.05)
      math.report_grade(65, 0.15)
      math.report_grade(70, 0.80)
      gym = poeun.get_subject('체육')
      gym.report_grade(100, 0.40)
      gym.report_grade(85, 0.60)
      print(poeun.average_grade()) # 119.27083333333334
    



Better way 38: 간단한 인터페이스의 경우 클래스 대신 함수를 받아라

  • 파이썬 내장 API 중 상당수는 함수를 전달해서 동작을 원하는 대로 바꿀 수 있게 해준다.
  • API가 실행되는 과정에서 사용자가 전달한 함수를 실행하는 경우, 이런 함수를 훅(hook)이라고 부른다.
  • 다음 코드는 key 훅으로 len 내장 함수를 전달해서 이름이 들어 있는 리스트를 이름의 길이에 따라 정렬한다.
  • 코드

      names =['소크라테스', '아르키메데스', '플라톤', '아리스토텔레스']
      names.sort(key=len)
      print(names) # ['플라톤', '소크라테스', '아르키메데스', '아리스토텔레스']
    


  • 함수는 클래스보다 정의하거나 기술하기가 더 쉬우므로 훅으로 사용하기에는 함수가 이상적이다.
  • 또한, 파이썬은 함수를 일급 시민 객체로 취급하기 때문에 함수를 훅으로 사용할 수 있다.
    • 일급 시민이란 언어 안에서 아무런 제약 없이 사용할 수 있는 데이터 값을 뜻한다.
  • 예를 들어, defaultdict 클래스의 동작을 사용자 정의하고 싶다고 해본다.
  • defaultdict에는 딕셔너리 안에 없는 키에 접근할 경우 호출되는 인자가 없는 함수를 전달할 수 있다.
  • 다음 코드는 존재하지 않는 키에 접근할 때 로그를 남기고 0을 디폴트 값으로 반환한다.
    • log_missing과 같은 함수를 사용할 수 있으면 정해진 동작과 부수 효과를 분리할 수 있기 때문에 API를 더 쉽게 만들 수 있다.
  • 코드

      def log_missing():
          print('키 추가됨')
          return 0
    


  • 원본 딕셔너리와 변경할 내용이 주어진 경우, log_missing 함수는 로그를 두 번 남길 수 있다.
  • 코드

      from collections import defaultdict
    
      current = {'초록': 12, '파랑': 3}
      increments = {
          ('빨강', 5),
          ('파랑', 17),
          ('주황', 9)
      }
      result = defaultdict(log_missing, current)
      print('이전', dict(result))
      for key, amount in increments:
          result[key] += amount
      print('이후', dict(result))
    
      # 결과
      # 이전 {'초록': 12, '파랑': 3}
      # 키 추가됨
      # 키 추가됨
      # 이후 {'초록': 12, '파랑': 20, '빨강': 5, '주황': 9}
    


  • 다음 코드는 이런 클로저가 있는 도우미 함수를 디폴트 값 훅으로 사용한다.
  • 코드

      def increment_with_report(current, increments):
          added_count = 0
    
          def missing():
              nonlocal added_count # 상태가 있는 클로저
              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
    


  • 파이썬에서는 클래스에 __call__ 특별 메서드를 정의할 수 있다.
  • __call__을 사용하면 객체를 함수처럼 호출할 수 있다.
  • 그리고 __call__이 정의된 클래스의 인스턴스에 대해 callable 내장 함수를 호출하면, 다른 일반 함수나 메서드와 마찬가지로 True가 반환된다.
    • 이런 방식으로 정의돼서 호출될 수 있는 모든 객체를 호출 가능(callable) 객체라고 부른다.
    • 즉, 상태를 저장하는 클로저 역할이다.
  • 코드

      class CountMissing:
          def __init__(self):
              self.added = 0
            
          def __call__(self):
              self.added += 1
              return 0
    
      counter = CountMissing()
      assert counter() == 0
      assert callable(counter)
    


  • 다음 코드는 CountMissing 인스턴스를 defaultdict의 디폴트 값 훅으로 사용해서 존재하지 않는 키에 접근한 횟수를 추적한다.
  • 코드

      counter = CountMissing()
      result = defaultdict(counter, current) # __call__에 의존함
      for key, amount in increments:
          result[key] += amount
      assert counter.added == 2
    



Better way 39: 객체를 제너릭하게 구성하려면 @classmethod를 통한 다형성을 활용하라

  • 다형성을 사용하면 계층을 이루는 여러 클래스가 자신에게 맞는 유일한 메서드 버전을 구현할 수 있다.
    • 이는 같은 인터페이스를 만족하거나 같은 추상 기반 클래스를 공유하는 많은 클래스가 서로 다른 기능을 제공할 수 있다는 뜻이다.
  • 예를 들어 맵리듀스(MapReduce) 구현을 하나 작성하고 있는데, 입력 데이터를 표현할 수 있는 공통 클래스가 필요하다고 해본다.
  • 다음 코드는 이럴 때 사용하기 위해 정의한, 하위 클래스에서 다시 정의해야만 하는 read 메서드가 들어 있는 공통 클래스를 보여준다.
    • @classmethod가 적용된 클래스 메서드는 공통 인터페이스를 통해 새로운 InputData 인스턴스를 생성한다.
      • @classmethod를 사용하면 클래스에 다른 생성자를 정의할 수 있다.
    • generate_inputs는 GenericInputData의 구체적인 하위 클래스가 객체를 생성하는 방법을 알려주는 설정 정보가 들어 있는 딕셔너리를 파라미터로 받는다.
  • 코드

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


  • 이 InputData의 구체적인 하위 클래스를 만들면서 디스크에서 파일을 읽게 할 수 있다.
  • PathInputData와 같이 원하면 얼마든지 InputData의 하위 클래스를 만들 수 있다.
  • 다음 코드는 입력 파일이 들어 있는 디렉터리를 찾기 위해 이 config를 사용한다.
  • 코드

      class PathInputData(GenericInputData):
          def __init__(self, path):
              super().__init__()
              self.path = path
    
          def read(self):
              with open(self.path) 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))
    


  • 이 입력 데이터를 소비하는 공통 방법을 제공하는 맵리듀스 워커(worker)로 쓸 수 있는 추상 인터페이스를 정의한다.
    • create_workers가 __init__ 메서드를 직접 호출하지 않고 cls()를 호출함으로써 다른 방법으로 GenericWorker 객체를 만들 수 있다.
  • 코드

      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
    


  • 다음 코드는 원하는 맵리듀스 기능을 구현하는 Worker의 구체적인 하위 클래스다.
  • 코드

      class LineCountWorker(GenericWorker):
          def map(self):
              data = self.input_data.read()
              self.result = data.count('\n')
            
          def reduce(self, other):
              self.result += other.result
    


  • 다음 코드는 디렉터리의 목록을 얻어서 그 안에 들어 있는 파일마다 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 단계를 여러 스레드에 공급해서 실행할 수 있다.
  • 그 후 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
    


  • mapreduce 함수가 create_workers를 호출하게 변경해서 mapreduce를 완전한 제너릭 함수로 만들 수 있다.
  • 코드

      def mapreduce(worker_class, input_class, config):
          workers = worker_class.create_workers(input_class, config)
          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))
    
      config = {'data_dir', tmpdir}
      result = mapreduce(LineCountWorker, PathInputData, config)
      print(f'총 {result} 줄이 있습니다.')
    



Better way 40: super로 부모 클래스를 초기화하라

  • 자식 클래스에서 부모 클래스를 초기화하는 오래된 방법은 자식 인스턴스에서 부모 클래스의 __init__ 메서드를 직접 호출하는 것이다.
    • 이 접근 방법은 기본적인 클래스 계층의 경우에는 잘 작동하지만, 다른 경우에는 잘못될 수 있다.
  • 코드

      class MyBaseClass:
          def __init__(self, value):
              # 여기서 객체에게 사용자가 제공한 값을 저장한다.
              # 사용자가 제공하는 값은 문자열로 탕비 변환이 가능해야 하며
              # 일단 한번 객체 내부에 설정되고 나면
              # 불변 값으로 취급돼야 한다.
              self.value = value
    
      class MyChildClass(MyBaseClass):
          def __init__(self):
              MyBaseClass.__init__(self, 5)
    


  • 다중 상속을 사용하는 경우 생기는 문제 중 하나는 모든 하위 클래스에서 __init__ 호출의 순서가 정해져 있지 않다는 것이다.
  • super를 사용하면 다이아몬드 계층의 공통 상위 클래스를 단 한번만 호출하도록 보장한다.
    • 다이아몬드 상속이란 어떤 클래스가 두 가지 서로 다른 클래스를 상속하는데, 두 상위 클래스의 상속 계층을 거슬러 올라가면 조상 클래스가 존재하는 경우를 뜻한다.
    • MRO(Method Resolution Order)는 상위 클래스를 초기화하는 순서를 결정한다.
  • 코드

      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('7 * (5 + 9) = 98 형태로 나와야 함', foo.value)
    



Better way 41: 기능을 합성할 때는 믹스인 클래스를 사용하라

  • 다중 상속이 제공하는 편의와 캡슐화가 필요하지만 다중 상속으로 인해 발생할 수 있는 골치 아픈 경우는 피하고 싶다면, 믹스인(mix-in)을 사용할지 고려해본다.
    • 믹스인은 자식 클래스가 사용할 메서드 몇 개만 정의하는 클래스다.
    • 믹스인 클래스에는 자체 애트리뷰트 정의가 없으므로 믹스인 클래스의 __init__ 메서드를 호출할 필요도 없다.



Better way 42: 비공개 이튜리뷰트보다는 공개 애트리뷰트를 사용하라

  • 파이썬에서 클래스의 애트리뷰트에 대한 가시성은 공개(public)비공개(private), 두 가지밖에 없다.
  • 코드

      class MyObject:
          def __init__(self):
              self.public_field = 5
              self.__private_field = 10
            
          def get_private_field(self):
              return self.__private_field
    
      foo = MyObject()
      assert foo.public_field == 5
    
  • 객체 뒤에 점 연산자(.)를 붙이면 공개 애트리뷰트에 접근할 수 있다.
  • 애트리뷰트 이름 앞에 밑줄을 두 개 (__) 붙이면 비공개 필드가 된다.
    • 비공개 필드를 포함하는 클래스 안에 있는 메서드에서는 해당 필드에 직접 접근할 수 있다.



Better way 43: 커스텀 컨테이너 타입은 collections.abc를 상속하라

  • 모든 파이썬 클래스는 함수와 애트리뷰트를 함께 캡슐화하는 일종의 컨테이너라 할 수 있다.
  • 파이썬은 데이터를 관리할 때 사용할 수 있도록 리스트, 튜플, 집합, 딕셔너리 등의 내장 컨테이너 타입을 제공한다.
  • 파이썬에서는 특별한 이름의 인스턴스 메서드(__getitem__)를 사용해 컨테이너의 동작을 구현한다.





References

댓글남기기