31. 고급 클래스 기능
30장에서는 인스턴스 속성과 메서드를 가진 기본 클래스를 만드는 방법을 배웠습니다. 이제 객체가 어떻게 동작하는지에 대해 세밀하게 제어할 수 있게 해주는 더 정교한 클래스 기능을 살펴보겠습니다. 이런 기능을 사용하면 덧셈, 비교, 인덱싱 같은 연산을 자연스러운 문법으로 처리하면서, 내장 Python 타입처럼 느껴지는 클래스를 만들 수 있습니다.
31.1) 클래스 변수 vs 인스턴스 변수
클래스에서 속성(attribute)을 만들 때, 이를 저장할 수 있는 근본적으로 서로 다른 두 위치가 있습니다. 클래스 자체에 저장하는 방법과 개별 인스턴스에 저장하는 방법입니다. 이 차이를 이해하는 것은 올바른 객체지향 코드를 작성하는 데 매우 중요합니다.
31.1.1) 인스턴스 변수 이해하기
인스턴스 변수(instance variable) 는 특정 객체에 속하는 속성입니다. 각 인스턴스는 이런 변수의 별도 복사본을 각각 가집니다. 30장 전반에서 인스턴스 변수를 사용해 왔는데, self를 사용해서 __init__에서 만드는 속성이 바로 그것입니다.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # 인스턴스 변수
self.balance = balance # 인스턴스 변수
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
print(account1.balance) # Output: 1000
print(account2.balance) # Output: 500각 BankAccount 인스턴스는 자신만의 owner와 balance를 가집니다. account1.balance를 변경해도 account2.balance에는 영향을 주지 않습니다. 둘은 완전히 독립적입니다.
31.1.2) 클래스 변수 이해하기
클래스 변수(class variable) 는 특정 인스턴스가 아니라 클래스 자체에 속하는 속성입니다. 모든 인스턴스가 같은 클래스 변수를 공유합니다. 클래스 변수는 어떤 메서드 바깥, 클래스 본문에 직접 정의합니다.
class BankAccount:
interest_rate = 0.02 # 클래스 변수 - 모든 인스턴스가 공유
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def apply_interest(self):
self.balance += self.balance * BankAccount.interest_rate
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
print(account1.interest_rate) # Output: 0.02
print(account2.interest_rate) # Output: 0.02
print(BankAccount.interest_rate) # Output: 0.02interest_rate는 인스턴스(account1.interest_rate)를 통해서도, 클래스 자체(BankAccount.interest_rate)를 통해서도 접근할 수 있다는 점을 확인하세요. 둘 다 같은 변수를 가리킵니다.
클래스 변수가 강력한 이유는 여기에 있습니다. 클래스 변수를 바꾸면 모든 인스턴스가 그 변경을 보게 됩니다.
class BankAccount:
interest_rate = 0.02
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
print(account1.interest_rate) # Output: 0.02
print(account2.interest_rate) # Output: 0.02
# 클래스 변수 변경
BankAccount.interest_rate = 0.03
print(account1.interest_rate) # Output: 0.03
print(account2.interest_rate) # Output: 0.03두 인스턴스는 모두 같은 클래스 변수를 바라보고 있기 때문에 즉시 새 이자율을 확인합니다.
31.1.3) 섀도잉 함정: 인스턴스 변수가 클래스 변수를 가릴 때
미묘하지만 중요한 동작이 하나 있습니다. 인스턴스를 통해 어떤 속성에 값을 대입하면, Python은 클래스 변수를 가리는(숨기는) 인스턴스 변수를 만들어 버립니다.
class BankAccount:
interest_rate = 0.02
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
# 클래스 변수를 가리는 인스턴스 변수 생성
account1.interest_rate = 0.05
print(account1.interest_rate) # Output: 0.05 (instance variable)
print(account2.interest_rate) # Output: 0.02 (class variable)
print(BankAccount.interest_rate) # Output: 0.02 (class variable)이제 account1은 클래스 변수를 가리는 자신만의 interest_rate 인스턴스 변수를 갖게 됩니다. 클래스 변수는 여전히 존재하지만, account1.interest_rate는 대신 인스턴스 변수를 가리킵니다. 이는 보통 원하는 동작이 아닙니다. 클래스 변수를 변경해야 한다면 인스턴스를 통해 바꾸지 말고 클래스 이름을 통해 변경하세요.
31.1.4) 클래스 변수의 실용적인 사용 예
클래스 변수는 모든 인스턴스에서 공유되어야 하는 데이터에 유용합니다.
class Student:
school_name = "Python High School" # 모든 학생에게 동일
total_students = 0 # 학생이 몇 명 존재하는지 추적
def __init__(self, name, grade):
self.name = name
self.grade = grade
Student.total_students += 1 # 학생 생성 시 증가
def __str__(self):
return f"{self.name} (Grade {self.grade}) at {Student.school_name}"
student1 = Student("Alice", 10)
student2 = Student("Bob", 11)
student3 = Student("Carol", 10)
print(student1) # Output: Alice (Grade 10) at Python High School
print(f"Total students: {Student.total_students}") # Output: Total students: 3__init__에서 Student.total_students( self.total_students가 아님)를 사용하는 방식에 주목하세요. 이렇게 하면 인스턴스 변수를 만드는 것이 아니라 클래스 변수를 수정하고 있다는 점이 명확해집니다.
31.2) @property로 속성 관리하기
때로는 누군가 속성에 접근하거나 수정할 때 어떤 일이 일어날지 제어하고 싶을 때가 있습니다. 예를 들어 값이 양수인지 검증하고 싶거나, 값을 저장하는 대신 필요할 때마다 즉석에서 계산하고 싶을 수 있습니다. Python의 @property 데코레이터(decorator)를 사용하면 단순한 속성 접근처럼 보이게 하면서도 메서드를 작성할 수 있습니다.
31.2.1) 문제: 직접 속성 접근으로는 검증할 수 없음
속성에 직접 접근하면 값을 검증하거나 변환할 수 없습니다:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
temp = Temperature(25)
print(temp.celsius) # Output: 25
# 물리적으로 불가능한 온도 설정을 막을 방법이 없습니다
temp.celsius = -500 # 절대영도(-273.15°C) 이하!
print(temp.celsius) # Output: -500
# 또는 터무니없이 높은 값
temp.celsius = 1000000
print(temp.celsius) # Output: 1000000검증이 없으면 실수로 잘못된 데이터를 설정할 수 있고, 이는 나중에 프로그램에서 버그를 일으킵니다. get_celsius()와 set_celsius() 같은 메서드를 사용할 수도 있지만, 이는 관용적인 Python 방식이 아닙니다. Python 개발자들은 Java나 C++처럼 getter/setter 메서드를 통하지 않고 속성에 직접 접근하기를 기대합니다.
31.2.2) 계산되는 속성에 @property 사용하기
@property 데코레이터는 메서드를 속성처럼 접근되는 "게터(getter)"로 바꿉니다.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def fahrenheit(self):
"""섭씨를 화씨로 즉석에서 변환"""
return self.celsius * 9/5 + 32
temp = Temperature(25)
print(temp.celsius) # Output: 25
print(temp.fahrenheit) # Output: 77.0 (저장된 값이 아니라 계산된 값)temp.fahrenheit를 괄호 없이 호출한다는 점에 주목하세요. 속성에 접근하는 것처럼 보이지만 실제로는 메서드를 호출하는 것입니다. 화씨 값은 접근할 때마다 계산되므로 항상 섭씨 값과 동기화됩니다.
temp = Temperature(0)
print(temp.fahrenheit) # Output: 32.0
temp.celsius = 100
print(temp.fahrenheit) # Output: 212.0 (자동으로 업데이트됨)31.2.3) @property_name.setter로 세터 추가하기
프로퍼티에 값을 설정할 수 있게 하려면 @property_name.setter 데코레이터를 사용해 세터(setter) 메서드를 추가합니다.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def fahrenheit(self):
return self.celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""설정 시 화씨를 섭씨로 변환"""
self.celsius = (value - 32) * 5/9
temp = Temperature(0)
print(temp.celsius) # Output: 0
print(temp.fahrenheit) # Output: 32.0
# 화씨로 온도 설정
temp.fahrenheit = 212
print(temp.celsius) # Output: 100.0
print(temp.fahrenheit) # Output: 212.0세터 메서드는 새 값을 인자로 받아서 저장하기 전에 검증하거나 변환할 수 있습니다.
31.2.4) 검증을 위해 프로퍼티 사용하기
프로퍼티는 제약 조건을 강제하는 데 매우 유용합니다.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self._balance = balance # 언더스코어는 "내부 용도"를 의미합니다
@property
def balance(self):
"""현재 잔액 가져오기"""
return self._balance
@balance.setter
def balance(self, value):
"""잔액 설정(단, 음수가 아니어야 함)"""
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
account = BankAccount("Alice", 1000)
print(account.balance) # Output: 1000
account.balance = 1500 # 정상 동작
print(account.balance) # Output: 1500
# 여기서는 에러가 발생합니다
account.balance = -100
# Output: ValueError: Balance cannot be negative명명 규칙(naming convention)에 주목하세요: 실제 값은 _balance(앞에 언더스코어가 있음)에 저장하고 balance 프로퍼티를 통해 노출합니다. 언더스코어는 "이것은 내부 구현 세부사항입니다"를 나타내는 Python 관례이지만, 기술적으로는 여전히 접근 가능합니다. 이 패턴을 사용하면 실제 저장소를 별도로 유지하면서 프로퍼티를 통해 접근을 제어할 수 있습니다.
31.2.5) 읽기 전용 프로퍼티
세터 없이 프로퍼티를 정의하면 읽기 전용(read-only)이 됩니다.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
"""계산되는 읽기 전용 프로퍼티"""
return self.width * self.height
rect = Rectangle(5, 3)
print(rect.area) # Output: 15
rect.width = 10
print(rect.area) # Output: 30 (자동으로 업데이트됨)
# area를 설정하려고 하면 에러가 발생합니다
rect.area = 50
# Output: AttributeError: property 'area' of 'Rectangle' object has no setter이는 저장하지 않고 계산되어야 하는 파생 값(derived value)에 유용합니다.
31.3) @classmethod를 사용하는 클래스 메서드
때로는 인스턴스가 아니라 클래스 자체와 동작하는 메서드가 필요할 수 있습니다. 클래스 메서드(class method) 는 첫 번째 인자로 인스턴스(self) 대신 클래스(관례적으로 cls)를 받습니다.
31.3.1) 클래스 메서드 정의하기
클래스 메서드는 @classmethod 데코레이터로 만듭니다.
class Student:
school_name = "Python High School"
def __init__(self, name, grade):
self.name = name
self.grade = grade
@classmethod
def get_school_name(cls):
"""클래스 메서드 - 인스턴스가 아니라 클래스를 받음"""
return cls.school_name
# 클래스 자체에서 호출
print(Student.get_school_name()) # Output: Python High School
# 인스턴스에서 호출할 수도 있습니다(하지만 cls는 여전히 클래스)
student = Student("Alice", 10)
print(student.get_school_name()) # Output: Python High Schoolcls 파라미터는 일반 메서드에서 self가 인스턴스를 자동으로 받는 것처럼, 자동으로 클래스를 받습니다.
31.3.2) 클래스 메서드로 대안 생성자 만들기
클래스 메서드의 가장 흔한 용도 중 하나는 대안 생성자(alternative constructor)입니다. 인스턴스를 만드는 또 다른 방법을 제공하는 것입니다.
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
@classmethod
def from_string(cls, date_string):
"""'2024-12-27' 같은 문자열로부터 Date 생성"""
year, month, day = date_string.split('-')
return cls(int(year), int(month), int(day))
@classmethod
def today(cls):
"""오늘 날짜의 Date 생성(단순화된 예시)"""
# 실제 코드에서는 datetime 모듈을 사용합니다
return cls(2024, 12, 27)
def __str__(self):
return f"{self.year}-{self.month:02d}-{self.day:02d}"
# 일반 생성자
date1 = Date(2024, 12, 27)
print(date1) # Output: 2024-12-27
# 문자열로부터 생성하는 대안 생성자
date2 = Date.from_string("2024-12-27")
print(date2) # Output: 2024-12-27
# 오늘 날짜를 위한 대안 생성자
date3 = Date.today()
print(date3) # Output: 2024-12-27from_string과 today가 모두 cls(...)를 반환한다는 점에 주목하세요. 이는 클래스의 새 인스턴스를 생성합니다. Date를 하드코딩하지 않고 cls를 사용하면 서브클래스에서도 코드가 올바르게 동작합니다(상속은 32장에서 배웁니다).
31.3.3) 팩토리 패턴을 위한 클래스 메서드
클래스 메서드는 서로 다른 설정으로 인스턴스를 생성하는 데 유용합니다.
class DatabaseConnection:
def __init__(self, host, port, database, username):
self.host = host
self.port = port
self.database = database
self.username = username
@classmethod
def for_development(cls):
"""개발 환경용으로 설정된 연결 생성"""
return cls("localhost", 5432, "dev_db", "dev_user")
@classmethod
def for_production(cls):
"""프로덕션 환경용으로 설정된 연결 생성"""
return cls("prod.example.com", 5432, "prod_db", "prod_user")
def __str__(self):
return f"Connection to {self.database} at {self.host}:{self.port}"
# 미리 설정된 연결을 쉽게 생성
dev_conn = DatabaseConnection.for_development()
prod_conn = DatabaseConnection.for_production()
print(dev_conn) # Output: Connection to dev_db at localhost:5432
print(prod_conn) # Output: Connection to prod_db at prod.example.com:543231.3.4) 인스턴스 개수 세기를 위한 클래스 메서드
클래스 메서드는 클래스 변수와 함께 작동하여 모든 인스턴스에 대한 정보를 추적할 수 있습니다.
class Product:
total_products = 0
def __init__(self, name, price):
self.name = name
self.price = price
Product.total_products += 1
@classmethod
def get_total_products(cls):
"""생성된 제품의 총 개수 반환"""
return cls.total_products
@classmethod
def reset_count(cls):
"""제품 카운터 초기화"""
cls.total_products = 0
product1 = Product("Laptop", 999)
product2 = Product("Mouse", 25)
product3 = Product("Keyboard", 75)
print(Product.get_total_products()) # Output: 3
Product.reset_count()
print(Product.get_total_products()) # Output: 031.4) @staticmethod를 사용하는 정적 메서드
정적 메서드(static method) 는 첫 번째 인자로 인스턴스(self)나 클래스(cls)를 받지 않는 메서드입니다. 단지 클래스 안에 정의되어 있을 뿐, 사실상 그 클래스와 논리적으로 관련된 일반 함수입니다.
31.4.1) 정적 메서드 정의하기
정적 메서드는 @staticmethod 데코레이터로 만듭니다.
class MathUtils:
@staticmethod
def is_even(number):
"""숫자가 짝수인지 확인"""
return number % 2 == 0
@staticmethod
def is_prime(number):
"""숫자가 소수인지 확인(단순화)"""
if number < 2:
return False
for i in range(2, int(number ** 0.5) + 1):
if number % i == 0:
return False
return True
# 클래스에서 정적 메서드를 호출
print(MathUtils.is_even(4)) # Output: True
print(MathUtils.is_even(7)) # Output: False
print(MathUtils.is_prime(17)) # Output: True
print(MathUtils.is_prime(18)) # Output: False
# 인스턴스에서도 호출할 수 있지만(동일한 함수입니다)
utils = MathUtils()
print(utils.is_even(10)) # Output: True정적 메서드는 인스턴스나 클래스 데이터에 접근할 필요가 없으며, 자체적으로 완결된 유틸리티 함수입니다.
31.4.2) 정적 메서드 vs 클래스 메서드 vs 인스턴스 메서드: 언제 무엇을 쓸까
선택 기준은 다음과 같습니다.
class Temperature:
# 클래스 변수
absolute_zero_celsius = -273.15
def __init__(self, celsius):
self.celsius = celsius
# 인스턴스 메서드 - 인스턴스 데이터(self)에 접근 필요
def to_fahrenheit(self):
return self.celsius * 9/5 + 32
# 클래스 메서드 - 클래스 데이터(cls)에 접근 필요
@classmethod
def get_absolute_zero(cls):
return cls.absolute_zero_celsius
# 정적 메서드 - 인스턴스나 클래스 데이터가 필요 없음
@staticmethod
def celsius_to_kelvin(celsius):
return celsius + 273.15
@staticmethod
def fahrenheit_to_celsius(fahrenheit):
return (fahrenheit - 32) * 5/9
temp = Temperature(25)
# 인스턴스 메서드 - 인스턴스 데이터 사용
print(temp.to_fahrenheit()) # Output: 77.0
# 클래스 메서드 - 클래스 데이터 사용
print(Temperature.get_absolute_zero()) # Output: -273.15
# 정적 메서드 - 단순 유틸리티 함수
print(Temperature.celsius_to_kelvin(25)) # Output: 298.15
print(Temperature.fahrenheit_to_celsius(77)) # Output: 25.0가이드라인:
- 인스턴스 메서드는 인스턴스 속성에 접근해야 할 때 사용합니다 (
self) - 클래스 메서드는 클래스 속성에 접근해야 하거나 대체 생성자가 필요할 때 사용합니다 (
cls) - 정적 메서드는 인스턴스나 클래스 데이터에 접근할 필요는 없지만, 함수가 클래스와 논리적으로 연관되어 있을 때 사용합니다
참고: 정적 메서드는 독립적인 함수로도 만들 수 있지만, 클래스 안에 두면 관련된 기능을 함께 묶고 전역 네임스페이스를 어지럽히지 않을 수 있습니다.
| 메서드 타입 | 첫 번째 매개변수 | 사용 시기 |
|---|---|---|
| 인스턴스 메서드 | self | 인스턴스 데이터에 접근해야 할 때 |
| 클래스 메서드 | cls | 클래스 데이터에 접근하거나 대체 생성자가 필요할 때 |
| 정적 메서드 | (없음) | 클래스와 관련된 유틸리티 함수 |
31.4.3) 실용 예시: 검증 유틸리티
정적 메서드는 검증과 유틸리티 함수에 아주 적합합니다.
class User:
def __init__(self, username, password):
if not User.is_valid_username(username):
raise ValueError("Invalid username")
if not User.is_valid_password(password):
raise ValueError("Invalid password")
self.username = username
self._password = password
@staticmethod
def is_valid_username(username):
"""username이 요구 사항을 만족하는지 확인"""
return len(username) >= 3 and username.isalnum()
@staticmethod
def is_valid_password(password):
"""password가 보안 요구 사항을 만족하는지 확인"""
return len(password) >= 8 and any(c.isdigit() for c in password)
# 이 검증 메서드들은 독립적으로 사용할 수 있습니다
print(User.is_valid_username("alice123")) # Output: True
print(User.is_valid_username("ab")) # Output: False
print(User.is_valid_password("pass1234")) # Output: True
# 그리고 클래스의 어떤 메서드에서도 사용할 수 있습니다
try:
user = User("ab", "short")
except ValueError as e:
print(f"Error: {e}") # Output: Error: Invalid username31.5) 특수 메서드(매직 메서드) 이해하기
Python의 특수 메서드(special method)(또는 매직 메서드(magic method), 혹은 이름에 밑줄이 두 개씩 들어가서 던더 메서드(dunder method) 라고도 부릅니다)는 내장 연산으로 객체가 어떻게 동작할지 커스터마이징할 수 있게 해줍니다. 30장에서는 이미 __init__, __str__, __repr__를 사용했습니다. 이제 더 많은 메서드를 살펴보겠습니다.
31.5.1) 특수 메서드가 하는 일
특수 메서드는 특정 문법이나 내장 함수를 사용할 때 Python이 자동으로 호출합니다.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"Point({self.x}, {self.y})"
point = Point(3, 4)
# print()를 호출하면 Python이 __str__()을 호출합니다
print(point) # Output: Point(3, 4)
# 이는 다음과 동일합니다: print(point.__str__())특수 메서드를 사용하면 내장 타입처럼 동작하는 클래스를 만들 수 있습니다. 예를 들어 객체가 다음을 지원하게 만들 수 있습니다.
- 산술 연산(
+,-,*,/) - 비교(
<,>,==) len(),in, 인덱싱과 함께 동작- 컨테이너나 시퀀스처럼 동작
31.5.2) 자주 쓰는 특수 메서드 범주
특수 메서드의 주요 범주는 다음과 같습니다.
문자열 표현(String Representation):
__str__()-print()와str()용__repr__()- REPL과repr()용
비교(Comparison):
__eq__()-==용__ne__()-!=용__lt__()-<용__le__()-<=용__gt__()->용__ge__()->=용
산술(Arithmetic):
__add__()-+용__sub__()--용__mul__()-*용__truediv__()-/용
컨테이너/시퀀스(Container/Sequence):
__len__()-len()용__contains__()-in용__getitem__()- 인덱싱obj[key]용__setitem__()- 대입obj[key] = value용
다음 절들에서 이를 자세히 살펴보겠습니다.
31.6) 예시 1: 컬렉션 인터페이스(len, contains)
아이템 컬렉션을 관리하는 클래스를 만들고, Python의 내장 len() 함수와 in 연산자와 함께 동작하게 해보겠습니다.
31.6.1) len()을 위한 len 구현하기
__len__() 특수 메서드는 객체에 len()을 사용할 때 호출됩니다.
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def __len__(self):
"""카트에 담긴 아이템 개수 반환"""
return len(self.items)
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
cart.add_item("Orange")
# len()이 __len__()을 호출합니다
print(len(cart)) # Output: 3__len__()이 없다면 len(cart) 호출은 TypeError를 발생시킵니다. 이를 구현하면 ShoppingCart가 내장 컬렉션처럼 동작합니다.
31.6.2) in 연산자를 위한 contains 구현하기
__contains__() 특수 메서드는 in 연산자를 사용할 때 호출됩니다.
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def __len__(self):
return len(self.items)
def __contains__(self, item):
"""아이템이 카트에 있는지 확인"""
return item in self.items
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
# in 연산자가 __contains__()를 호출합니다
print("Apple" in cart) # Output: True
print("Orange" in cart) # Output: False이제 카트는 멤버십 테스트를 위한 자연스러운 Python 문법을 지원합니다.
31.6.3) 더 완성도 있는 컬렉션 클래스 만들기
학생 성적을 추적하는 좀 더 현실적인 컬렉션 클래스를 만들어 보겠습니다.
class GradeBook:
def __init__(self):
self.grades = {} # student_name: list of grades
def add_grade(self, student, grade):
"""학생의 성적을 추가"""
if student not in self.grades:
self.grades[student] = []
self.grades[student].append(grade)
def __len__(self):
"""학생 수 반환"""
return len(self.grades)
def __contains__(self, student):
"""학생에게 성적이 하나라도 있는지 확인"""
return student in self.grades
def get_average(self, student):
"""학생의 평균 성적 가져오기"""
if student not in self:
return None
grades = self.grades[student]
return sum(grades) / len(grades)
def __str__(self):
return f"GradeBook with {len(self)} students"
gradebook = GradeBook()
gradebook.add_grade("Alice", 85)
gradebook.add_grade("Alice", 90)
gradebook.add_grade("Bob", 78)
gradebook.add_grade("Bob", 82)
gradebook.add_grade("Bob", 88)
print(gradebook) # Output: GradeBook with 2 students
print(len(gradebook)) # Output: 2
print("Alice" in gradebook) # Output: True
print("Carol" in gradebook) # Output: False
print(f"Alice's average: {gradebook.get_average('Alice')}") # Output: Alice's average: 87.5
print(f"Bob's average: {gradebook.get_average('Bob')}") # Output: Bob's average: 82.66666666666667get_average()가 if student not in self를 사용한다는 점에 주목하세요. 이는 우리가 구현한 __contains__() 메서드를 호출하므로 코드가 자연스럽게 읽힙니다.
31.7) 예시 2: 연산자 오버로딩(add, eq, lt)
연산자 오버로딩(operator overloading) 이란 +, ==, < 같은 연산자가 커스텀 클래스에서 무엇을 의미하는지 정의하는 것입니다. 이를 통해 객체가 Python 문법과 자연스럽게 어울리게 됩니다.
31.7.1) 덧셈을 위한 add 구현하기
__add__() 특수 메서드는 + 연산자를 사용할 때 호출됩니다.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
"""두 벡터 더하기"""
return Vector(self.x + other.x, self.y + other.y)
def __str__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
# + 연산자가 __add__()를 호출합니다
v3 = v1 + v2
print(v3) # Output: Vector(4, 6)Python은 v1 + v2를 보면 v1.__add__(v2)를 호출합니다. 왼쪽 피연산자의 __add__() 메서드는 오른쪽 피연산자를 인자로 받습니다.
31.7.2) 동등 비교를 위한 eq 구현하기
__eq__() 특수 메서드는 == 연산자를 사용할 때 호출됩니다.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
"""두 벡터가 같은지 확인"""
return self.x == other.x and self.y == other.y
def __str__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(1, 2)
v3 = Vector(3, 4)
# == 연산자가 __eq__()를 호출합니다
print(v1 == v2) # Output: True
print(v1 == v3) # Output: False__eq__()이 없으면 Python은 값이 아니라 객체 동일성(메모리에서 같은 객체인지)을 비교합니다. __eq__()을 구현하면 우리 클래스에서 "같음"의 의미를 정의할 수 있습니다.
31.7.3) 비교 연산자 구현하기
Money 클래스에 비교 연산자를 구현해 봅시다.
class Money:
def __init__(self, amount):
self.amount = amount
def __eq__(self, other):
"""금액이 같은지 확인"""
return self.amount == other.amount
def __lt__(self, other):
"""이 금액이 other보다 작은지 확인"""
return self.amount < other.amount
def __le__(self, other):
"""이 금액이 other보다 작거나 같은지 확인"""
return self.amount <= other.amount
def __gt__(self, other):
"""이 금액이 other보다 큰지 확인"""
return self.amount > other.amount
def __ge__(self, other):
"""이 금액이 other보다 크거나 같은지 확인"""
return self.amount >= other.amount
def __str__(self):
return f"${self.amount:.2f}"
price1 = Money(10.50)
price2 = Money(15.75)
price3 = Money(10.50)
print(price1 == price3) # Output: True
print(price1 < price2) # Output: True
print(price1 <= price3) # Output: True
print(price2 > price1) # Output: True
print(price2 >= price1) # Output: True31.7.4) 연산자에서 타입 불일치 처리하기
연산자를 구현할 때는 다른 피연산자가 기대한 타입이 아닐 때의 경우도 처리해야 합니다.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
"""두 벡터를 더하거나 스칼라를 두 성분에 더하기"""
if isinstance(other, Vector):
return Vector(self.x + other.x, self.y + other.y)
elif isinstance(other, (int, float)):
return Vector(self.x + other, self.y + other)
else:
return NotImplemented # Python이 other.__radd__(self)를 시도하게 함
def __eq__(self, other):
if not isinstance(other, Vector):
return False
return self.x == other.x and self.y == other.y
def __str__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Output: Vector(4, 6) (vector addition)
print(v1 + 5) # Output: Vector(6, 7) (scalar addition)
print(v1 == v2) # Output: False
print(v1 == "not a vector") # Output: False (no error)NotImplemented(특별한 내장 상수)를 반환하면 Python에게 다른 피연산자에 대한 반사 연산(reflected operation)을 시도하라고 알립니다. 이는 서로 다른 타입과 함께 연산자가 올바르게 동작하도록 만드는 데 중요합니다.
31.8) 예시 3: 시퀀스 접근(getitem, setitem)
__getitem__()과 __setitem__() 특수 메서드를 사용하면 커스텀 클래스에 인덱싱 문법(obj[key])을 사용할 수 있습니다. 이렇게 하면 객체가 리스트, 딕셔너리, 다른 시퀀스처럼 동작합니다.
31.8.1) 인덱싱을 위한 getitem 구현하기
__getitem__() 메서드는 대괄호로 아이템에 접근할 때 호출됩니다.
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __getitem__(self, index):
"""인덱스로 노래 가져오기"""
return self.songs[index]
def __len__(self):
return len(self.songs)
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
# 인덱싱이 __getitem__()를 호출합니다
print(playlist[0]) # Output: Song A
print(playlist[1]) # Output: Song B
print(playlist[-1]) # Output: Song C (음수 인덱싱도 동작!)self.songs[index]로 위임(delegate)하기 때문에, 리스트의 모든 인덱싱 기능이 자동으로 동작합니다. 양수 인덱스, 음수 인덱스, 그리고 잘못된 인덱스에 대한 IndexError 발생까지 모두 포함됩니다.
31.8.2) __getitem__으로 슬라이싱 지원하기
같은 __getitem__() 메서드는 슬라이싱도 처리합니다.
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __getitem__(self, index):
"""인덱스 또는 슬라이스로 노래 가져오기"""
return self.songs[index]
def __len__(self):
return len(self.songs)
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
playlist.add_song("Song D")
# 슬라이싱도 __getitem__()를 호출합니다
print(playlist[1:3]) # Output: ['Song B', 'Song C']
print(playlist[:2]) # Output: ['Song A', 'Song B']
print(playlist[::2]) # Output: ['Song A', 'Song C']슬라이싱을 사용하면 Python은 slice 객체를 __getitem__()에 전달합니다. self.songs[index]로 위임하면 모든 슬라이스 문법을 자동으로 지원하게 됩니다.
31.8.3) 대입을 위한 setitem 구현하기
__setitem__() 메서드는 특정 인덱스에 값을 대입할 때 호출됩니다.
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __getitem__(self, index):
return self.songs[index]
def __setitem__(self, index, value):
"""특정 인덱스의 노래 교체"""
self.songs[index] = value
def __len__(self):
return len(self.songs)
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
print(playlist[1]) # Output: Song B
# 대입이 __setitem__()를 호출합니다
playlist[1] = "New Song B"
print(playlist[1]) # Output: New Song B31.8.4) __getitem__으로 객체를 이터러블하게 만들기
흥미로운 부작용 하나가 있습니다. 0부터 시작하는 정수 인덱스를 처리하는 __getitem__()을 구현하면, 객체는 자동으로 iterable이 됩니다.
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __getitem__(self, index):
return self.songs[index]
def __len__(self):
return len(self.songs)
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
# for 반복문이 자동으로 동작합니다!
for song in playlist:
print(song)
# Output:
# Song A
# Song B
# Song CPython은 __getitem__(0)을 호출한 뒤, __getitem__(1)을 호출하는 식으로 IndexError를 받을 때까지 계속 호출하며 반복(iterate)하려고 합니다. 이는 오래된 반복 프로토콜이며, 최신 이터레이터 프로토콜은 35장에서 배우겠습니다.
31.8.5) 문자열 키로 딕셔너리처럼 접근하기
__getitem__()과 __setitem__()은 정수뿐 아니라 어떤 타입의 키에서도 동작합니다.
class ScoreBoard:
def __init__(self):
self.scores = {}
def __getitem__(self, player_name):
"""플레이어의 점수를 가져옵니다"""
return self.scores.get(player_name, 0)
def __setitem__(self, player_name, score):
"""플레이어의 점수를 설정합니다"""
self.scores[player_name] = score
def __contains__(self, player_name):
return player_name in self.scores
def __len__(self):
return len(self.scores)
scoreboard = ScoreBoard()
# 문자열 키를 사용하여 점수 설정
scoreboard["Alice"] = 100
scoreboard["Bob"] = 85
# 점수 업데이트
scoreboard["Alice"] = 120
# 점수 조회
print(scoreboard["Alice"]) # Output: 120
print(scoreboard["Bob"]) # Output: 85
print(scoreboard["Carol"]) # Output: 0
print("Alice" in scoreboard) # Output: True
print(len(scoreboard)) # Output: 2이 장에서는 Python 문법과 매끄럽게 통합되는 정교한 클래스를 만드는 방법을 보여주었습니다. 클래스 변수, 프로퍼티, 클래스 메서드, 정적 메서드, 특수 메서드를 구현하면 커스텀 클래스가 내장 타입처럼 동작하게 만들 수 있습니다. 32장에서는 상속(inheritance)과 다형성(polymorphism)을 살펴보는데, 이를 통해 동작을 공유하고 확장하는 관련 클래스들의 계층을 구축할 수 있습니다.