데이터를 어떻게 다루어야 하는지는 그 데이터의 유형에 따라 결정된다. 수 데이터는 반올림 연산을 할 수 있지만, 문자열 데이터는 그럴 수 없다. 똑같이 + 연산자로 계산하더라도 수는 합을 구하지만 문자열은 연결한 문자열('a' + 'b' == 'ab')을 구한다.

이렇게 데이터 유형마다 연산 방식이 다른 것은, 파이썬에 이 데이터 유형들이 그렇게 정의되어 있기 때문이다. 데이터 유형은 데이터의 취급 방식을 결정하기 위해 필요한 것이므로, 여러분도 데이터 유형을 정의할 때 그 데이터 유형에 맞는 연산을 정의할 필요가 있다. 8.3절에서 배운 메서드를 활용해 이용해 데이터 유형(클래스)에 따라 데이터(인스턴스)를 다루는 연산을 정의하는 방법을 알아보자.

8.5.1 데이터 유형의 인터페이스

인터페이스(interface, 접점)는 한 대상이 다른 대상과 맞닿는 면이다. 전자 제품이나 소프트웨어처럼 내부 구조가 복잡한 제품에서는 인터페이스가 사용자가 제품을 사용하는 방법이 된다. 우리는 텔레비전의 원리를 모르더라도 리모컨을 사용해 쉽게 텔레비전을 시청할 수 있고, 전기 이론을 몰라도 콘센트와 플러그를 이용해 안전하게 전기 에너지를 이용할 수 있다. 리모컨·플러그와 같이 인터페이스를 친절하게 만들어 놓으면 사용자가 복잡한 내부 구조를 신경쓰지 않고도 쉽고 안전하게 제품을 사용할 수 있다.

다루기 어렵고 복잡한 데이터 유형을 정의해야 할 때도 마찬가지 원리를 적용할 수 있다. 좋은 클래스에는 쉽고 안전한 인터페이스가 있다. 예를 들어, 부동소수점 수(float)는 사칙연산과 반올림 등의 연산을 제공한다. +, - 등의 연산자와 round() 등의 함수를 사용하면 되는데, 이 연산들이 바로 부동소수점 수의 인터페이스다.

클래스를 정의할 때, 클래스의 속성을 감추어두고 연산자와 메서드를 인터페이스로 제공하는 방법을 캡슐화(encapsulation)라고 부른다. 부동 소수점 수를 직접 구현해 실수 연산을 수행하려면 부호·가수부·지수부 등 여러 속성을 정의하고 조작해야 할 것이다. 하지만 파이썬의 float 클래스는 사칙연산 연산자와 round() 함수를 사용할 수 있게 정의되어 있어서 클래스의 속성을 직접 조작할 필요가 없다. 캡슐화를 하면 클래스를 사용하기 쉽게 정의할 수 있고, 속성을 잘못 조작하는 실수도 막을 수 있다.

개념 정리

  • 인터페이스: 어떤 대상이 다른 대상과 맞닿는 면. 클래스의 인터페이스가 간편하면 복잡한 세부 사항을 모르더라도 인스턴스를 쉽고 안전하게 취급할 수 있다.
  • 캡슐화: 속성을 감추고, 연산자와 메서드를 제공하여 사용하도록 하는 클래스 정의 방법.

메서드를 이용해 속성 조작하기

다음은 과일 주스를 나타내는 클래스다. 과일 주스에는 몇몇 과일만 재료로 넣을 수 있다. 올바르지 않은 재료를 넣으려고 하면, 안 된다는 메시지를 출력하기로 하자.

코드 8-51 과일 케익을 나타내는 클래스

class FruitJuice():
    """과일 주스를 나타내는 클래스"""
    valid_fruits = {'귤', '복숭아', '청포도', '딸기', '사과'}  # 넣을 수 있는 과일
    
    def __init__(self):
        """인스턴스를 초기화한다."""
        self.ingredients = []   # 주스에 들어가는 재료
    
    def add_ingredient(self, ingredient):
        """재료(ingredient)를 이 주스에 추가한다."""
        if ingredient in self.valid_fruits:
            self.ingredients.append(ingredient)
        else:
            print(ingredient, '는 과일 주스에 넣을 수 없습니다.')
    
    def describe(self):
        """이 주스에 관한 정보를 화면에 출력한다."""
        print('이 주스에는', len(self.ingredients), '개의 재료가 들어 있습니다.')
        print('넣은 재료:', end=' ')
        for ingredient in self.ingredients:
            print(ingredient, end=', ')

위 클래스를 따라 입력해 보고, 이 클래스의 속성은 무엇이 있고 인터페이스(어떤 방식으로 사용해야 하는지)는 무엇인지 생각해보자. 다음 내용을 확인할 수 있었는가?

  • 각 주스 인스턴스는 재료 리스트(ingredients)를 속성으로 가진다.
  • 주스 인스턴스를 생성할 때 초기값을 지정하지 않는다.
  • 주스에 재료를 추가할 때 add_ingredient() 메서드를 사용한다.
  • 주스의 정보를 화면에 출력할 때 describe() 메서드를 사용한다.

확인한 인터페이스에 따라, 과일 주스 클래스의 인스턴스를 만들어 사용해 보자.

코드 8-52 메서드를 이용해 인스턴스 조작하기

juice_1 = FruitJuice()
juice_1.add_ingredient('청포도')  # 재료 추가하기
juice_1.add_ingredient('복숭아')  # 재료 추가하기
juice_1.add_ingredient('도라지')  # 잘못된 재료 추가하기
juice_1.describe()

실행 결과:

도라지 는 과일 주스에 넣을 수 없습니다.
이 주스에는 2 개의 재료가 들어 있습니다.
넣은 재료: 청포도, 복숭아,

주스 재료를 추가할 때, juice_1.ingredients 속성을 직접 조작하지 않고 add_ingredient() 메서드를 이용하면 된다. 인스턴스의 내부 속성이 어떤 식으로 구현되어 있는지 신경쓸 필요가 없다. 잘못된 재료를 입력하는 것도 메서드가 막아 준다. 메서드를 제공하지 않고 클래스를 사용하는 프로그래머가 ingredients 속성을 직접 조작하도록 했다면, 실수로 도라지를 넣어 주스 맛을 망칠 수도 있었다.

개념 정리

  • 클래스를 정의할 때는 클래스의 내부 속성과 구현 방식을 클래스 감추고, 클래스를 조작하는 방법을 메서드로 제공하는 것이 좋다.

비공개 속성

하지만 클래스를 사용하는 프로그래머가 메서드를 통하지 않고 속성을 직접 조작할 수도 있다. add_ingredient() 메서드가 있는 줄 모르고, ingredients 속성을 조작하면 어떻게 될까?

코드 8-53 인스턴스의 속성을 직접 조작하는 문제

juice_2 = FruitJuice()
juice_2.ingredients.append('도라지')  # 속성을 직접 조작하기
juice_2.describe()

실행 결과:

이 주스에는 1 개의 재료가 들어 있습니다.
넣은 재료: 도라지,

이처럼, ingredient 속성을 직접 조작하면 잘못된 재료를 넣는 것이 가능하다. 클래스를 정의할 때는 클래스 사용자자 속성을 잘못 조작하는 것을 막기 위해 속성을 비공개로 정의할 필요가 있다. 비공개 속성이란 클래스 내부 메서드에서는 가리킬 수 있지만 클래스 밖에서는 직접 가리킬 수 없는 속성이다.

파이썬 문화에서는 속성 이름을 지을 때, _secret과 같이 밑줄 기호(_) 하나로 시작하면 “이 속성 비공개 속성이니 직접 조작하지 마세요”라는 의미다. 필요하다면 _secret을 읽고 조작할 수도 있지만, 그 책임은 클래스를 사용하는 프로그래머에게 따른다. 다음은 FruitJuice 클래스에서 비공개로 취급되어야 할 속성의 이름을 밑줄로 시작하도록 수정한 것이다.

코드 8-54 밑줄 기호로 비공개 속성 나타내기

class FruitJuice():
    """과일 주스를 나타내는 클래스"""
    _valid_fruits = {'귤', '복숭아', '청포도', '딸기', '사과'}  # 넣을 수 있는 과일
    
    def __init__(self):
        """인스턴스를 초기화한다."""
        self._ingredients = []   # 주스에 들어가는 재료
    
    def add_ingredient(self, ingredient):
        """재료(ingredient)를 이 주스에 추가한다."""
        if ingredient in self._valid_fruits:
            self._ingredients.append(ingredient)
        else:
            print(ingredient, '는 과일 주스에 넣을 수 없습니다.')
    
    def describe(self):
        """이 주스에 관한 정보를 화면에 출력한다."""
        print('이 주스에는', len(self._ingredients), '개의 재료가 들어 있습니다.')
        print('넣은 재료:', end=' ')
        for ingredient in self._ingredients:
            print(ingredient, end=', ')

클래스 속성 _valid_fruits와 인스턴스 속성 _ingredients에 밑줄 기호를 이용해 비공개 속성임을 표시했다. 이제 이 클래스를 사용하는 프로그래머들은 이 속성을 직접 조작해서는 안 되고 공개 메서드를 이용해야 한다는 걸 알아볼 것이다.

개념 정리

  • 클래스에서 밑줄 기호 하나로 시작하는 속성은 비공개 속성이다. 직접 조작하면 안 된다.

연습문제

연습문제 8-12 주사위

다음 조건에 맞춰 주사위를 나타내는 클래스(Dice)를 정의하여라.

  • 각 주사위 객체마다 면의 수가 다르다. 예를 들어 육면체 주사위는 6개의 면을 갖는다. 이 ‘면의 수’를 비공개 인스턴스 속성으로 정의하라.
  • 각 주사위 객체는 항상 어느 한 면이 위를 향하고 있으며, 그 면은 1과 ‘면의 수’ 사이의 자연수다. 이 ‘나온 면’을 비공개 인스턴스 속성으로 정의하라.
  • 주사위 인스턴스는 Dice(sides)와 같이 하나의 인자를 전달하여 생성한다. 인스턴스화 과정에서 ‘면의 수’는 sides가 전달받는 값으로, ‘나온 면’은 자신이 가질 수 있는 임의의 값으로 초기화된다.
  • 인스턴스의 현재 ‘나온 면’을 반환하는 top() 메서드를 정의하라.
  • 인스턴스의 ‘나온 면’을 새 임의의 값으로 설정하고 반환하는 role() 메서드를 정의하라.
  • n 이상 m 이하의 임의의 수는 random.randint(n, m) 함수를 사용하여 구한다. 예를 들어 1 이상 8 이하의 임의의 수는 random.randint(1, 8)이다. (이 함수를 사용하려면 먼저 random 모듈을 임포트해야 한다.)

클래스를 정의한 후 프로그램 하단에 다음 코드를 삽입해 테스트해 보아라.

dice_4 = Dice(4)      # 사면체 주사위 생성
print('사면체 주사위 테스트 ----')
print('처음 나온 면:', dice_4.top())
print('다시 굴리기:', dice_4.roll())
print('다시 굴리기:', dice_4.roll())

dice_100 = Dice(100)  # 백면체 주사위 생성
print('백면체 주사위 테스트 ----')
print('처음 나온 면:', dice_100.top())
print('다시 굴리기:', dice_100.roll())
print('다시 굴리기:', dice_100.roll())

이 프로그램을 실행한 결과는 다음과 같다. 임의의 값이 사용되므로 나온 수는 차이가 있을 것이다.

사면체 주사위 테스트 ----
나온 면: 2
다시 굴리기: 2
다시 굴리기: 1
백면체 주사위 테스트 ----
나온 면: 42
다시 굴리기: 54
다시 굴리기: 79

8.5.2 연산자의 동작 정의하기

클래스의 전용 연산은 메서드로 제공될 때가 많지만, +, - 같은 연산자로 연산을 수행할 수 있게 지원하는 클래스도 있다. 예를 들어, 문자열은 대소문자 변환과 문자 개수 세기 등의 연산을 메서드로 제공하지만, 문자열과 문자열을 서로 연결하는 연산은 덧셈 연산자(+)로 제공한다. 이처럼 클래스 전용 연산 중에는 메서드보다 연산자를 이용할 때 더 직관적이고 간결한 경우가 있다.

‘맛’과 ‘칼로리’라는 두 속성을 가진 ‘음식’ 클래스를 정의하며, 클래스에서 연산자의 동작을 정의하는 방법을 알아보자.

코드 8-55 음식 클래스

class Food:
    """음식을 나타내는 클래스"""
    def __init__(self, taste, calorie):              # ❶
        """인스턴스를 초기화한다."""
        self._taste = taste      # 맛
        self._calorie = calorie  # 칼로리
    
    def to_string(self):                             # ❷
        """이 음식을 표현하는 문자열을 반환한다."""
        return str(self._taste) + '만큼 맛있고, ' + str(self._calorie) + '만큼 든든한 음식'
    
    def add(self, other):                            # ❸
        """이 음식(self)과 다른 음식(other)을 더한
        새 음식을 반환한다."""
        taste = self._taste + other._taste           # 두 음식의 맛을 더한다
        calorie = self._calorie + other._calorie     # 두 음식의 칼로리를 더한다
        return Food(taste, calorie)                  # 새 음식 인스턴스를 생성하여 반환한다

Food 클래스의 인스턴스는 _taste_calorie 두 속성으로 맛과 칼로리를 나타낸다. ❷ to_string() 메서드를 이용해 문자열로 표현할 수 있으며, ❸ add() 메서드를 이용해 두 음식을 서로 더할 수도 있게 하였다. 다음은 Food 클래스를 대화식 셸에 입력하고, 인스턴스화하여 사용해 본 예다.

코드 8-56 Food 클래스 사용하기

>>> food1 = Food(7, 85)
>>> print(food1.to_string())  # ❶ 음식 인스턴스를 문자열로 표현하기
7만큼 맛있고, 85만큼 든든한 음식

>>> food2 = Food(12, 266)
>>> print(food2.to_string())
12만큼 맛있고, 266만큼 든든한 음식

>>> food3 = food1.add(food2)  # ❷ 두 음식 인스턴스 합하기
>>> print(food3.to_string())
19만큼 맛있고, 351만큼 든든한 음식

음식 객체는 ❶ to_string() 메서드를 이용해 문자열로 변환하여 print() 함수로 출력할 수 있고, ❷ add() 메서드로 두 음식을 합하는 것도 가능하다. 모두 의도대로 잘 수행된다. 그런데 음식 객체를 to_string() 메서드를 사용하지 않고 화면에 출력하면 무엇이 출력될까? 또, add() 메서드 대신 덧셈 연산자(+)로 두 인스턴스를 더하면 어떻게 될까?

코드 8-57 Food 클래스의 부족한 점

>>> print(food1)   # ❶ 음식 객체를 그냥 출력했을 때: 알기 힘든 내용 출력
<__main__.Food object at 0x7fc527d50f60>

>>> food1 + food2  # ❷ 음식 객체를 덧셈 연산자로 더할 때: 오류 발생
TypeError: unsupported operand type(s) for +: 'Food' and 'Food'

❶ 음식 객체를 print() 함수에 그냥 넘겨 출력하면 “메모리의 0x7fc527d50f60번째 위치에 존재하는 Food 형식의 객체”라는 의미의 정보만 출력되는데, 객체의 의미를 알기 어렵다. 이보다는 to_string() 메서드의 결과를 출력하는 편이 더 유용할 것 같다. ❷ 덧셈 연산자로 두 객체를 더하면 FoodFood을 더하는 연산이 지원되지 않는다며 TypeError 오류가 발생한다. 덧셈 연산자로도 add() 메서드와 같이 두 음식의 합을 구할 수 있으면 좋겠다.

이중 밑줄 메서드

print(food1)처럼, 인스턴스를 문자열로 표현내야 하는 경우에는 클래스에 정의된 __str__()이라는 메서드가 저절로 호출된다. 마찬가지로, 인스턴스를 + 연산자로 더하려 하면 __add__()라는 메서드가 저절로 호출된다. 이처럼 문자열 변환·연산자 실행 등 특정한 경우에 저절로 실행되는 메서드들은 모두 밑줄 기호 두 개로 시작하고 밑줄 기호 두 개로 끝나며, 이중 밑줄 메서드라고 부른다. 인스턴스를 초기화할 때 호출되는 __init__() 메서드도 이중 밑줄 메서드 중의 하나다. Food 클래스에 __str__() 메서드와 __add__() 메서드를 정의해 보자.

코드 8-58 __add__() 메서드와 __str__() 메서드 정의하기

class Food:
    """음식을 나타내는 클래스"""
    def __init__(self, taste, calorie):
        """인스턴스를 초기화한다."""
        self._taste = taste      # 맛
        self._calorie = calorie  # 칼로리
    
    def __str__(self):           # ❶ to_string() 메서드의 이름을 __str__()로 수정했다
        """이 음식을 표현하는 문자열을 반환한다."""
        return str(self._taste) + '만큼 맛있고, ' + str(self._calorie) + '만큼 든든한 음식'
    
    def __add__(self, other):    # ❷ add() 메서드의 이름을 __add__()로 수정했다
        """이 음식(self)과 다른 음식(other)을 더한
        새 음식을 반환한다."""
        taste = self._taste + other._taste           # 두 음식의 맛을 더한다
        calorie = self._calorie + other._calorie     # 두 음식의 칼로리를 더한다
        return Food(taste, calorie)                  # 새 음식 인스턴스를 생성하여 반환한다

코드 8-58은 코드 8-55에서 to_string() 메서드와 add() 메서드의 이름을 각각 ❶ __str__()와 ❷ __add__()로 수정했을 뿐이다. 이제 음식 객체를 문자열로 변환하는 방법과 두 음식 객체를 합하는 방법이 지정되었다. 실제로 음식 객체 두 개를 합하여 화면에 출력해보면, 이중 밑줄 메서드가 저절로 호출되어 원하는 결과를 얻을 수 있다.

코드 8-59 이중 밑줄 메서드가 자동으로 호출된다

>>> print(Food(7, 85) + Food(12, 266))
19만큼 맛있고, 351만큼 든든한 음식

연산자가 덧셈만 있는 것은 아니므로, 이 외에도 다양한 이중 밑줄 메서드가 있다. 표 8-1에서 몇 가지만 소개한다. 이것을 모두 외울 필요는 없고 가끔 연산자에 따른 동작을 정의해야할 때 찾아보면 충분하다.

메서드 호출되는 연산 기능
__init__(self) 인스턴스화 인스턴스 초기화
__abs__(self) abs(self) 절대값 계산
__add__(self, other) self + other 덧셈 계산
__sub__(self, other) self - other 뺄셈 계산
__mul__(self, other) self * other 곱셈 계산
__truediv__(self, other) self / other 나눗셈 계산
__pow__(self, other) self ** other 거듭제곱 계산
__floordiv__(self, other) self // other 몫 계산
__mod__(self, other) self % other 나머지 계산
__lt__(self, other) self < other 미만 계산
__gt__(self, other) self > other 초과 계산
__le__(self, other) self <= other 이하 계산
__ge__(self, other) self >= other 이상 계산
__eq__(self, other) self == other 동등 계산
__ne__(self, other) self != other 부등 계산
__repr__(self) repr(self) 객체를 식별할 수 있는 문자열 반환
__str__(self) str(self) 객체에 대응하는 문자열 반환
__int__(self) int(self) 객체에 대응하는 정수 반환
__float__(self) float(self) 객체에 대응하는 실수 반환
__bool__(self) bool(self) 객체에 대응하는 불리언 값 반환

표 8-1 이중 밑줄 메서드 (self: 호출 기준 객체, other: +, - 등의 이항 연산자에서 우변에 위치하는 객체)

__repr__()__str__()

__repr__() 메서드와 __str__() 메서드는 둘 다 객체를 설명하는 문자열을 반환한다는 점에서 비슷하지만, 객체를 설명하는 방식은 서로 다르다. __repr__()은 객체를 다른 객체와 구별하는 정보(고유번호, 속성 내용 등)를 문자열로 반환한다. 이 정보는 주로 프로그래머가 보기 위한 것으로, 예를 들어 프로그램 실행 기록(로그)에서 객체를 기록할 때 사용할 수 있다. 반면, __str__()이 반환하는 문자열은 객체의 의미와 내용을 사람이 읽기 좋은 형태로 표현한 것으로, 주로 프로그램을 사용하는 일반 사용자가 보기 위한 것이다.

그동안 사용해 온 정수 연산, 문자열 연산 등은 실제로는 int 클래스와 str 클래스에 정의된 이중 밑줄 메서드를 호출한 것과 같다. 이 점은 이중 밑줄 메서드를 직접 호출하여 확인할 수 있다.

코드 8-60 연산자 대신 이중 밑줄 메서드로 연산하기

>>> number = 10
>>> number.__eq__(20)   # number == 20
False

>>> number.__mul__(5)   # number * 5
50

>>> number.__lt__(20)   # number < 20
True

>>> number.__float__()  # float(number)
10.0

>>> number.__str__()    # str(number)
'10'

>>> number.__bool__()   # bool(number)
True

==, + 등의 연산자와 abs(), int() 함수 등이 실제로 하는 일은 객체의 이중 밑줄 메서드를 호출하는 것이다. 여러분이 새로 정의하는 클래스도 기본 데이터 유형처럼 다양한 연산자를 지원할 수 있다.

연산자에 연결하는 메서드는 연산자에 따른 동작을 직관적으로 유추할 수 있는 것이어야 한다. 가령, __add__() 메서드가 두 인스턴스의 크기를 비교하는 연산을 수행하도록 정의한다면 몹시 혼란스러울 것이다. 클래스의 인터페이스는 클래스를 처음 사용하는 사람도 오해 없이 쉽게 사용할 수 있도록 설계하는 것이 좋다.

개념 정리

  • 클래스에 다양한 이중 밑줄 메서드를 정의하여, 연산자가 클래스에 대해 수행할 연산을 직접 정의할 수 있다.

연습문제

연습문제 8-13 음식 클래스에 연산 추가하기

코드 8-58의 음식 클래스 Food에 크기 비교 연산을 추가해 보아라. 맛이 좋으면 더 큰 것이고, 같은 맛이면 칼로리가 더 적은 것이 더 큰 것이다. 맛과 칼로리가 모두 같으면 두 음식의 크기가 같다. 클래스를 정의한 후 그 아래에 다음 코드를 입력하여 잘 실행되는지 확인해 보아라.

strawberry = Food(9, 32)
potato = Food(6, 66)
sweet_potato = Food(12, 131)
pizza = Food(13, 266)
print('딸기 < 감자: ', strawberry < potato)
print('감자 + 감자 < 고구마: ', potato + potato < sweet_potato)
print('피자 >= 딸기: ', pizza >= strawberry)
print('피자 >= 피자: ', pizza >= strawberry)
print('감자 + 딸기 < 피자: ', potato + strawberry < pizza)
print('딸기 == 딸기: ', potato == potato)

이 프로그램의 올바른 실행 결과는 다음과 같다.

딸기 < 감자:  False
감자 + 감자 < 고구마:  True
피자 >= 딸기:  True
피자 >= 피자:  True
감자 + 딸기 < 피자:  False
딸기 == 딸기:  True

연습문제 8-14 연산자 재정의 장난

정수 데이터의 덧셈 연산이 정수 데이터의 곱셈으로 수행되도록 짓궂은 장난을 쳐 보자. 파이썬 대화식 셸에서 필요한 명령을 입력해보고, 그것이 허용되는지 아닌지를 이유와 함께 설명해 보아라.

힌트: int.__add__() 메서드가 호출되어야 할 때 int.__mul__() 메서드가 호출되도록 해야 한다.