지난 번 글이 이번 '스피드 연산 게임 만들기' 시리즈의 마지막 글일 줄 알았으나 메뉴 처리와 관련된 코드를 개선할 필요가 있다고 판단하여 시리즈를 이어나갈까 한다.
생각해 보면 메뉴는 우리가 컴퓨터에서 사용하는 폴더와 많은 부분에서 비슷한 구조를 갖는다. 아래 그림과 같이 폴더 안에는 서로 다른 유형의 파일들 뿐만 아니라 또다른 폴더도 있을 수 있다. 각각의 파일들을 선택해서 실행하면 해당 파일에 맞는 프로그램이 실행되고 폴더를 선택해서 들어가면 다시 폴더 안의 파일이나 폴더들이 보이게 된다. 상위 폴더로 이동도 가능하다.
메뉴도 이와 동일하게 하나의 메뉴가 여러 개의 하위 메뉴들을 가질 수 있고 그 하위 메뉴들 중에 어떤 메뉴들은 또다른 하위 메뉴들을 포함하고 있을 수 있다. 하위 메뉴를 포함하지 않는 메뉴는 폴더 안의 파일과 같이 그 메뉴가 선택되었을 때 그 메뉴에 해당하는 특정 기능이 실행되어야 하고 하위 메뉴를 포함하는 메뉴는 자신의 하위 메뉴들을 보여주고 선택을 받는 것이 그 메뉴의 기능이 되면 된다. 이러한 생각을 바탕으로 아래와 같이 코드를 작성하였다.
class Menu
attr_reader :name
attr_accessor :parent_menu
def initialize(name)
@name = name
end
def select
puts "#{name} 메뉴를 선택하셨습니다."
end
def get_input(prompt, check_empty = true)
while true
print prompt
input = STDIN.gets.chomp
return input if input != "" || !check_empty
end
end
private :get_input
end
class ExitMenu < Menu
end
class MenuList < Menu
def initialize(name)
super
@sub_menus = []
end
def add(menu)
menu.parent_menu = self
@sub_menus.push(menu)
end
def display
if parent_menu && !@sub_menus.last.instance_of?(ExitMenu)
add(ExitMenu.new("상위 메뉴로 가기"))
end
puts "\n[ #{name} ]"
no = 1
@sub_menus.each do |menu|
puts "#{no}. #{menu.name}"
no += 1
end
end
def select
while true
display
no = get_input("선택 > ").to_i
if no < 1 || no > @sub_menus.size
puts "메뉴 선택이 잘못되었습니다."
else
menu = @sub_menus[no - 1]
if menu.instance_of?(ExitMenu)
break
else
menu.select
end
end
end
end
private :display
end
우선 젤 위에 Menu 클래스를 정의하였다. 메뉴의 이름을 저장하는 @name 인스턴스 변수가 하나 있고 메뉴가 선택되었을 때 처리해야 기능이 들어갈 'select' 메서드와 사용자 입력을 받을 때 사용할 헬퍼(helper) 메서드 'get_input' 이 있다.
메뉴 처리에서 사용자 입력을 받는 것은 빈번한 일이므로 다른 코드에서 쉽게 재사용 할 수 있도록 만들어 두었다.
또한 이렇게 특정 기능을 하나의 메서드로 분리해 놓으면 이것을 사용하는 메서드의 코드가 이 기능을 메서드 내에서 직접 구현할 때 보다 복잡도가 내려가 코드 관리에 용이하다. 하나의 메서드가 너무 많은 기능을 담지 않도록 하는 것이 좋다.
그리고 Menu 클래스에 attr_accessor 메서드를 사용하여 부모 메뉴에 대한 정보를 관리할 수 있게 getter 와 setter 를 만들어 두었다.
get_input 메서드를 보면 파라미터가 두 개가 있는데 그 중 check_empty 는 사용자가 입력 없이 그냥 엔터를 누를 경우 다시 입력을 받을지 여부를 옵션으로 선택할 수 있게 해준다.
그 아래 ExitMenu 클래스는 Menu 클래스를 상속해서 정의했고 그 안에 어떠한 내용도 없다. 이 ExitMenu 클래스는 아래 MenuList 클래스의 코드를 보면 알겠지만 단지 상위 메뉴로 빠져나가기 위해 존재한다. 현재 메뉴가 최상위 메뉴라면 ExitMenu 는 메뉴를 종료시키게 된다.
이제 MenuList 클래스를 보자. 이 MenuList 클래스가 바로 폴더와 같은 역할을 하게 된다. add 메서드를 이용하여 하위 메뉴를 추가할 수 있는데 이때 add 메서드 안에서 하위 메뉴 객체의 부모 메뉴를 자신(self)으로 설정하고 있다.
display 메서드는 말 그대로 서브 메뉴들의 목록을 보여주는 기능인데 중요한 건 메뉴들을 보여주기 전에 가장 마지막 메뉴가 ExitMenu 가 아니면 ExitMenu 를 추가한다는 것이다. 이 ExitMenu 가 있어야 상위 메뉴로 갈 수 있기 때문에 꼭 추가해줘야 한다. 배열의 last 메서드는 마지막 요소를 반환하고, 어떤 객체에 대해 instance_of? 메서드를 호출하면 해당 객체가 인수로 건넨 클래스의 인스턴스인지를 불린 값으로 알려준다. 불린 값은 true 와 false 를 말하는데 루비에서 true 는 TrueClass 의 유일한 인스턴스이고 false 도 FalseClass 의 유일한 인스턴스이다. 따라서 true.instance_of?(TrueClass) 는 true 를 반환한다.
MenuList 클래스의 select 메서드를 보면 메뉴 선택을 여러번 반복할 수 있도록 메뉴를 보여주고 선택을 받아 처리하는 부분이 모두 while 루프 안에 들어 있다. 선택한 메뉴가 ExitMenu 객체이면 while 루프를 빠져나오고 그렇지 않으면 해당 메뉴 객체의 select 메서드를 호출한다. 객체 초기화를 위한 initialize 메서드를 보면 super 가 보이는데 이것은 상위 클래스에서 동일한 이름의 메서드를 찾아 호출해준다. 즉 MenuList 클래스의 initialize 메서드에서 super 를 사용했으므로 MenuList 클래스의 상위 클래스인 Menu 클래스에서 같은 이름을 갖는 메서드 즉 initialize 메서드를 호출한다.
super 를 사용할 때 인수를 지정해도 되지만 따로 지정하지 않으면 super 를 사용한 메서드가 호출될 때 받은 인수가 그대로 전달된다. 결국 super 를 통해 @name 인스턴스 변수가 초기화 된다.
이제 위에서 만든 메뉴 관련 코드가 잘 동작하는지 테스트해 보자. 테스트를 위해 아래의 코드를 'menu_test.rb' 파일에 작성한 후 실행하였다.
require './menu'
choice_quiz_menu = MenuList.new("객관식 문제 선택")
choice_quiz_menu.add(Menu.new("루비문제"))
choice_quiz_menu.add(Menu.new("영어문제"))
quiz_menu = MenuList.new("문제 유형 선택")
quiz_menu.add(Menu.new("구구단"))
quiz_menu.add(Menu.new("덧셈"))
quiz_menu.add(Menu.new("뺄셈"))
quiz_menu.add(Menu.new("나눗셈"))
quiz_menu.add(choice_quiz_menu)
top_menu = MenuList.new("게임 메뉴")
top_menu.add(ExitMenu.new("바로 게임 시작"))
top_menu.add(quiz_menu)
top_menu.add(Menu.new("게임 시간 설정"))
top_menu.select
작성한 대로 메뉴 기능이 잘 동작하는 걸 볼 수 있다. 이제 '구구단', '덧셈', '뺄셈', '게임 시간 설정' 등과 같이 하위 메뉴가 없는 메뉴들을 선택했을 때 실제 필요한 기능을 하도록 만드는 일이 남아 있다.
어떻게 하면 메뉴 처리 코드(Menu, MenuList 등)를 수정하지 않고 특정 프로그램에서 필요한 기능이 메뉴 선택 시 실행될 수 있도록 할 수 있을까? 그리고 그렇게 할 수 있어야만 메뉴 처리 코드를 여러 곳에서 재활용할 수 있게 된다.
배열의 each 메서드에서 힌트를 얻을 수 있는데 다음 글에서 살펴보도록 하자.
See you again~~