본문 바로가기

AP프로그래밍과 문제해결

4. [Python] 객체지향 프로그래밍: One-card game

1. 객체지향 개념 설명

객체지향 프로그래밍(Object-Oriented Programming, OOP)은 프로그램을 객체(object)라는 단위로 나누어 개발하는 방식이다. 객체는 데이터(속성)와 이를 조작하는 코드(메서드)를 포함하고 있으며, 이들을 통해 프로그램의 구조와 기능을 정의할 수 있다. 객체지향 프로그래밍의 주요 개념으로는 클래스(class), 객체(object), 상속(inheritance), 다형성(polymorphism), 캡슐화(encapsulation), 추상화(abstraction) 등이 있다. 각각의 개념에 대해 파이썬을 이용하여 개념을 구현하는 예제 코드를 덧붙여서 설명하겠다.

클래스(Class)

클래스는 객체를 생성하기 위한 청사진(또는 틀)이다. 클래스는 속성(데이터)과 메서드(함수)를 정의한다. 객체는 클래스의 인스턴스(instance)이며, 클래스를 기반으로 생성된다. 객체는 클래스에서 정의한 속성과 메서드를 상속받아 사용할 수 있다. 파이썬에서는 class 키워드를 사용하여 클래스를 정의하고, 클래스 내부에는 속성과 메서드를 정의할 수 있다.

#예시 코드
class Dog:
    # 클래스 변수
    species = "Canis familiaris"

    # 생성자 메서드
    def __init__(self, name, age):
        self.name = name  # 인스턴스 변수
        self.age = age    # 인스턴스 변수

    # 인스턴스 메서드
    def description(self):
        return f"{self.name} is {self.age} years old"

    # 인스턴스 메서드
    def speak(self, sound):
        return f"{self.name} says {sound}"

# 객체 생성
buddy = Dog("Buddy", 9)
miles = Dog("Miles", 4)

# 객체의 메서드 호출
print(buddy.description())  # Buddy is 9 years old
print(miles.speak("Woof Woof"))  # Miles says Woof Woof

위 예시에서 Dog 클래스는 개의 이름과 나이를 속성으로 가지며, descriptionspeak라는 두 개의 메서드를 정의하고 있다. __init__ 메서드는 생성자 메서드로, 객체가 생성될 때 호출되어 객체의 초기 상태를 설정한다.

 

상속(Inheritance): 상속은 하나의 클래스가 다른 클래스의 속성과 메서드를 물려받는 것을 의미한다. 이를 통해 코드의 재사용성을 높일 수 있다.

#예시 코드
class Bulldog(Dog):
    def run(self, speed):
        return f"{self.name} runs at {speed} km/h"

bruno = Bulldog("Bruno", 5)
print(bruno.description())  # Bruno is 5 years old
print(bruno.run(12))  # Bruno runs at 12 km/h

다형성(Polymorphism): 다형성은 같은 이름의 메서드가 다른 클래스에서 다르게 동작하도록 할 수 있는 것을 의미한다.

#예시 코드
class Cat:
    def speak(self, sound="Meow"):
        return f"Cat says {sound}"

animals = [Dog("Buddy", 9), Cat()]
for animal in animals:
    print(animal.speak())

캡슐화(Encapsulation): 캡슐화는 객체의 내부 상태를 외부에서 직접 접근하지 못하도록 하고, 메서드를 통해서만 접근할 수 있도록 하는 것이다. 이를 통해 객체의 상태를 보호할 수 있다.

#예시 코드
class Person:
    def __init__(self, name, age):
        self.__name = name  # 비공개 속성
        self.__age = age    # 비공개 속성

    def get_name(self):
        return self.__name

    def set_age(self, age):
        if age > 0:
            self.__age = age

    def get_age(self):
        return self.__age

john = Person("John", 30)
print(john.get_name())  # John
john.set_age(35)
print(john.get_age())  # 35

추상화(Abstraction): 추상화는 불필요한 세부 사항을 숨기고 중요한 부분만을 노출하는 것이다. 이를 통해 복잡성을 줄이고 코드의 가독성을 높일 수 있다.

 

객체지향 프로그래밍의 장단점은 다음과 같다.

장점

  1. 재사용성(Reusability): 객체지향 프로그래밍에서는 코드의 재사용이 용이하다. 상속을 통해 기존 클래스의 기능을 확장할 수 있으며, 이미 작성된 코드를 다시 사용할 수 있어 개발 시간과 비용을 절약할 수 있다.
  2. 확장성(Scalability): 새로운 기능이 필요할 때 기존 코드를 수정하지 않고도, 새로운 클래스를 추가함으로써 기능을 확장할 수 있다. 이는 프로그램의 확장성을 높이고 유지보수를 용이하게 한다.
  3. 유지보수성(Maintainability): 객체지향 프로그래밍에서는 프로그램을 여러 개의 객체로 나누어 관리하기 때문에, 코드의 수정 및 오류 검출이 용이하다. 코드의 일부분만 수정해도 전체 시스템에 쉽게 반영될 수 있다.
  4. 추상화(Abstraction): 사용자는 복잡한 내부 구현 세부사항을 몰라도 객체의 인터페이스를 통해 기능을 사용할 수 있다. 이는 프로그램의 복잡성을 줄이고 가독성을 높일 수 있다.
  5. 캡슐화와 정보 은닉(Encapsulation and Information Hiding): 객체의 세부 구현 내용을 숨기고 사용자에게는 필요한 인터페이스만을 제공한다. 이를 통해 객체의 독립성을 보장하고, 외부의 영향으로부터 객체의 상태를 보호할 수 있다.

단점

  1. 성능 저하(Performance): 객체지향 프로그래밍은 절차 지향 프로그래밍에 비해 상대적으로 실행 속도가 느릴 수 있다. 객체 간의 상호작용, 메모리 사용 등 때문이다.
  2. 복잡성(Complexity): 객체, 클래스, 상속, 다형성 등의 개념을 이해하고 사용하는 것은 어려울 수 있다. 
  3. 설계 시간(Design Time): 객체지향 프로그래밍은 효과적인 시스템을 설계하기 위해 상당한 시간과 노력을 필요로 한다. 초기 설계 단계에서 많은 시간을 할애해야 하며, 설계가 잘못되었을 경우 전체 시스템의 수정이 필요할 수 있다.
 

2. 문제 설계

문제: 사람 N명이 원카드 게임을 하고 있다. 원카드 게임은 트럼프 카드를 이용해서 하는 한국의 카드 게임으로,  플레이어들은 원 모양으로 둘러앉은 다음, 트럼프 카드를 중앙에 놓는다. 트럼프 카드를 섞고, 다 섞었으면 한 사람 당 일정량의 카드를 배분하고 맨 위의 카드 한 장을 그림이 보이도록 카드 뭉치 옆자리에 놓는다. 그리고 게임이 시작되면 플레이어들은 순서대로 자신의 패에서 보이게 뒤집어 놓은 카드와 동일한 숫자 또는 무늬의 카드를 뒤집은 카드 위에 계속해서 한 장씩 올려놓는다. 패를 가장 먼저 모두 소모하면 승리한다. 만약 남은 카드가 2장일 때 등, 낼 수 있는 카드를 모두 냈을 때 패에 남는 카드가 1장이 될 경우 '원카드'라고 선언하며 내야 한다. (그러나 본 문제에서 이 규칙은 무시하도록 하겠다) 플레이어의 수 N과 S번의 차례 동안 나온 각각의 카드가 주어질 때, 가장 남은 카드의 수가 적은 사람의 순서를 구하시오. 플레이어는 1번부터 N번까지 각각 번호가 매겨져 있고 번호 순서대로 차례를 진행하며, S번째 차례가 끝났을 때 만약 k번째 사람의 카드가 가장 적다면 k를 출력하면 된다.

특수 능력을 가진 카드는 다음과 같다.(이 경우 로컬 룰이 너무 많아 "나무위키"를 참고하였다)

2 카드 자신의 다음 차례 사람에게 2장을 추가로 먹인다. (지원 공격 불가능)
A 카드 자신의 다음 차례 사람에게 3장을 추가로 먹인다. (지원 공격 불가능)
joker 카드 자신의 다음 차례 사람에게 10장을 추가로 먹인다. (지원 공격 불가능)
3 카드 같은 모양의 2 카드를 방어할 수 있다. (본 게임에서는 3 카드를 2를 방어할 때 외에는 쓰지 않는다고 가정한다)
7 카드 자신이 원하는 무늬로 바꿀 수 있다.
K 카드 카드를 1장 더 낼 수 있다.
Q 카드 게임의 진행 방향을 반대로 바꾼다.
J 카드 다음 차례의 사람을 한 번 건너 뛰고 그 사람에게 턴을 넘긴다.

 

만약 카드가 나오지 않고 특정 차례에 0이 나온다면, 이는 그 차례의 플레이어가 낼 카드가 없어서 덱에서 카드를 1장 가져간 것을 의미한다.

입력: 첫 줄에 사람의 수 N, 차례의 수 S가 공백을 기준으로 입력된다. 이후 두번째 줄부터 S줄 동안 임의의 플레이어가 낸 카드의 모양( ♠, ♣, ♥, ♦️, none-0이나 joker의 경우)과 숫자(A,2,3,4,5,6,7,8,9,10,J,Q,K / 0, joker)가 공백을 기준으로 구분되어 입력된다. 

출력: S번의 차례가 모두 지난 후 가지고 있는 카드의 수가 가장 적은 플레이어의 번호를 출력한다.

입력 예시

5 6
none 0
♦️ 7
♠ 2
♠ Q
none joker
♠ 5

출력 예시

3

도움말

위의 예시와 같은 경우 1번째 사람이 카드를 1장 가져왔으므로 +1, 2번째 사람은 카드를 냈으므로 -1, 3번째 사람도 카드를 냈으므로 -1, 4번째 사람은 2로 인해 카드를 2장 먹고(+2) 다시 카드를 냈으므로 -1, Q에 의해 순서가 바뀌었으므로 다시 3번째 사람이 카드를 내게 되어 -1, 2번째 사람이 joker에 의해 +10, 이후 카드를 내게되어 -1까지 이렇게 순서가 진행된다. 그렇게 되면 최종적으로 3번째 사람이 -2가 되어 우승한다.

 

3. 문제 풀이

먼저, 각각의 플레이어의 상태를 저장할 클래스를 만든다. 이때, 각 플레이어의 초기 카드 수를 초기화한다.

class player:
    def __init__(self, cards):
        self.cards = cards

 

이후, 각 턴마다 나올 수 있는 경우에 따라 이를 이행하는 함수를 만든다. 이는 공격(2, 3, joker)과 방어(3), 특수카드(K,Q,J)로 경우를 나누어서 제작해보겠다.

    def attack(self, card_num):
        if card_num == '2':
            self.cards += 2
        elif card_num == 'A':
            self.cards += 3
        else:
            self.cards += 10

    def defense(self):
        self.cards -= 1
        self.cards -= 2

    def draw_cards(self):
        self.cards += 1

    def special_card(self, card_num, idx, direc):
        if card_num == 'K':
            idx -= direc
            self.cards -= 1
        elif card_num == 'Q':
            direc *= -1
            self.cards -= 1
        else:
            idx += direc
            self.cards -= 1
        return idx, direc

    def pass_turn(self):
        self.cards -= 1

attack 함수의 경우 card_num에 따라 다르게 카드를 추가해주었다. defense 함수의 경우 먼저 카드를 낸 것이므로 -1을 하고, 이후 2에 대한 방어이므로 attack에서 +2가 되었을 것을 -2로 상쇄시켜주었다. draw_cards는 그냥 카드를 가져온 것이므로 +1을 해주었다. special_cards 함수의 경우 card_num에 따라 K면 자신이 한번 더 하는 것이므로 이후 설정해줄 플레이 중인 플레이어의 번호를 나타내는 변수 idx를 -direc(idx에 direc을 더해가면서 턴이 진행된다)을 하여 자신이 한번 더 할 수 있게 하였고, Q이면 이후 설정해줄 진행 방향을 나타내는 변수 direc에 -1을 곱해 반대 방향으로 돌아갈 수 있도록 하였다. 이 둘 다 아니라면 J이므로 idx에 +direc을 미리 해주어 플레이어를 건너뛸 수 있게 하였다. 이 모든 special_cards의 경우는 카드를 내는 것이므로 -1을 해주었다. 마지막으로 모든 경우에 해당이 안 되는 경우 그냥 카드를 내는 것이므로 pass_turn 함수에서 -1을 해주었다.

 

이제 idx, dir 등 변수를 지정하고 입력을 받은 뒤 차례를 진행시켜 최종 출력 번호를 알아낼 수 있도록 코드를 짜보겠다.

idx = 0
direc = 1
N, S = map(int, input().split())
players = []
attacks = ['2', 'A', 'joker']
defenses = ['3']
special_cards = ['K', 'Q', 'J']

for i in range(N):
    players.append(player(0))  # 초기 카드 수는 어차피 동일하므로 0으로 설정

for i in range(S):
    card = list(input().split())
    if card[1] in attacks:
        players[idx].pass_turn()
        players[(idx + direc) % N].attack(card[1])
    elif card[1] in defenses:
        players[idx].defense()
    elif card[1] in special_cards:
        idx, direc = players[idx].special_card(card[1], idx, direc)
    elif card[1] == '0':
        players[idx].draw_cards()
    else:
        players[idx].pass_turn()
    idx = (idx + direc) % N
cards_by_players = []
for i in players:
    cards_by_players.append(i.cards)
print(cards_by_players.index(min(cards_by_players)) + 1)

각각의 N명의 플레이어에 대해 초기 카드 수는 어차피 동일하므로 모두 0으로 설정해주어 카드 수의 증감량을 비교한다. S번의 턴 동안 카드를 입력받아서 그 카드가 공격인지, 방어인지, 특수 카드인지 아닌지를 판별한 후 각각의 함수를 앞서 클래스에서 구현한 것으로 사용하였다. 특정 턴을 진행하는 플레이어의 번호를 idx로 설정하였고, 방향을 direc으로 설정하여 idx에 direc(+1 또는 -1)을 더해가며 턴을 진행시키도록 코드를 구성하였다.

 

전체 코드는 다음과 같다.

class player:
    def __init__(self, cards):
        self.cards = cards

    def attack(self, card_num):
        if card_num == '2':
            self.cards += 2
        elif card_num == 'A':
            self.cards += 3
        else:
            self.cards += 10

    def defense(self):
        self.cards -= 1
        self.cards -= 2

    def draw_cards(self):
        self.cards += 1

    def special_card(self, card_num, idx, direc):
        if card_num == 'K':
            idx -= direc
            self.cards -= 1
        elif card_num == 'Q':
            direc *= -1
            self.cards -= 1
        else:
            idx += direc
            self.cards -= 1
        return idx, direc

    def pass_turn(self):
        self.cards -= 1

idx = 0
direc = 1
N, S = map(int, input().split())
players = []
attacks = ['2', 'A', 'joker']
defenses = ['3']
special_cards = ['K', 'Q', 'J']

for i in range(N):
    players.append(player(0))  # 초기 카드 수는 어차피 동일하므로 0으로 설정

for i in range(S):
    card = list(input().split())
    if card[1] in attacks:
        players[idx].pass_turn()
        players[(idx + direc) % N].attack(card[1])
    elif card[1] in defenses:
        players[idx].defense()
    elif card[1] in special_cards:
        idx, direc = players[idx].special_card(card[1], idx, direc)
    elif card[1] == '0':
        players[idx].draw_cards()
    else:
        players[idx].pass_turn()
    idx = (idx + direc) % N
cards_by_players = []
for i in players:
    cards_by_players.append(i.cards)
print(cards_by_players.index(min(cards_by_players)) + 1)

실제로 위 코드에 앞선 예시 입력을 넣어도 올바른 출력값이 나오고, 다른 입력을 넣어도 올바른 값이 나온다.

 

4. 시행착오

객체 지향을 공부하고 본 문제를 구현하면서 어떤 문제를 만들어야 좋을지를 고민하는데 시간을 꽤 쓴 것 같다. 또한 본 문제를 객체지향으로 풀기 위해 설계하고 특히 idx와 direc 변수를 이용해서 특수 카드를 구현하는 부분에서 잘 안되었어서 시행착오를 겪었다. 각각의 턴별로 idx, direc, 각 플레이어들이 가진 카드 수의 증감량 등을 모두 출력해보며 디버깅을 하였고, 실제 손으로 푼 것과 동일한 결과가 출력되도록 코드를 수정해나갔다.

 

5. 느낀점

객체 지향을 사용하는 프로그래밍 문제를 만들고 직접 풀어보는 것은 이번이 처음이었는데, 상당히 흥미로웠다. 알고리즘과는 결이 다르게 게임을 직접 만들고 구현하는 것 같은 느낌이 들어서 재밌었다. 그러나 이런 프로그래밍 문제들은 객체 지향을 설계하는데 시간이 오래 걸려 그냥 절차지향으로 풀어도 크게 나쁠 건 없겠다라는 생각도 들기도 했다. 이런 객체지향은 실제 여러 업무나 인공지능 개발 등에서 굉장히 유용하게 사용될 수 있을 것 같다는 생각을 했다.