지난 글에서 메뉴 처리 기능을 별도로 분리하고 테스트까지 진행했었다. 이제 메뉴 처리 관련 코드를 수정하지 않고 메뉴 선택 시 원하는 기능이 처리될 수 있게 해보자. 지난 글의 마지막에 얘기했던 것처럼 배열의 each 메서드에서 힌트를 얻을 수 있는데 배열의 each 메서드는 배열 요소 하나 하나를 블록 파라미터에 넘겨 주면서 블록을 실행시켜 주는 기능을 한다.
따라서 블록의 내용에 따라 동일한 배열 객체를 가지고도 서로 다른 작업을 처리할 수 있다. 다시 말하면 배열의 'each' 메서드에 내가 원하는 '작업'을 블록을 통해 전달할 수 있고 메서드 내부에서 알아서 그 '작업'이 처리되도록 블록을 실행시켜 준다는 거다.
배열의 'each' 메서드를 이용한 몇 가지 예를 보자.
arr = (1..10).to_a
even_arr = []
arr.each { |e| even_arr.push(e) if e % 2 == 0 }
p even_arr # [2, 4, 6, 8, 10]
str_arr = []
arr.each { |e| str_arr.push(e.to_s) }
p str_arr # ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
sum = 0
arr.each { |e| sum += e }
p sum # 55
1부터 10까지의 정수를 담은 배열에 대해 each 메서드를 호출하여 세 가지 서로 다른 작업을 처리할 수 있었다.
물론 배열에는 저러한 작업을 쉽게 할 수 있는 메서드가 별도로 존재한다. 아래 코드를 보면 배열 요소에서 짝수만 걸러낼 때는 select 메서드로, 배열 요소를 모두 문자열로 변환하고 싶을 때는 map 메서드로, 배열 요소의 모든 합을 구할 때는 sum 메서드로 간단히 할 수 있다. 여기서 map 은 호출 대상 배열 자체를 변경하지 않고 새 배열을 생성해 돌려준다.
arr = (1..10).to_a
p arr.select { |e| e.even? }
p arr.map { |e| e.to_s }
p arr.sum
그렇다면 우리가 만든 메뉴 처리 코드에서 어떻게 블록을 사용할 수 있을까?
아래 간단한 블록 사용 예를 보면서 이해하도록 하자.
?> def foo
?> puts "foo start"
?> yield("ruby")
?> puts "foo end"
>> end
=> :foo
>>
>> foo { |e| puts "블록: #{e}" }
foo start
블록: ruby
foo end
=> nil
>> foo
foo start
LocalJumpError (no block given (yield))
foo 메서드의 코드를 보면 안에 yield 가 보이는데 이것은 foo 를 호출할 때 넘긴 블록을 실행시켜주는 역할을 한다. 그리고 위에서 처럼 블록 파라미터에 값을 넘길 수도 있다. 그런데 아래 예처럼 foo 호출 시 블록을 지정하지 않게 되면 yield 에서 LocalJumpError 예외가 발생한다. 프로그램의 실행 흐름이 메서드 안의 코드에서 블록 안의 코드로 넘어와야 하는데 블록이 없으니 당연히 문제가 생길 수 밖에 없다.
아래처럼 block_given? 메서드를 이용하여 메서드 호출 시에 블록이 함께 넘어왔는지를 검사하여 예외 발생을 피할 수도 있다. 물론 메서드 자체가 블록을 필수로 받아야 한다면 예외를 발생시키거나 기본 처리로 대신해야 한다.
?> def foo
?> puts "foo start"
?> yield("ruby") if block_given?
?> puts "foo end"
>> end
=> :foo
>>
>> foo
foo start
foo end
=> nil
그렇다면 메서드 호출 시 넘겨 받은 블록을 꼭 메서드 안에서 바로 실행하지 않고 변수에 보관했다가 나중에 필요할 때 실행할 수는 없을까? 물론 가능하다. 아래와 같이 메서드에 블록을 받기 위한 별도의 파라미터를 추가해 주면 된다. 중요한 건 블록을 메서드의 파라미터로 받기 위해서는 파라미터 이름 앞에 '&' 를 붙여줘야 하고 파라미터가 여러 개일 경우 젤 끝에 와야 한다.
?> class Foo
?> def initialize(val, &block)
?> @val = val
?> @block = block
?> end
?>
?> def run_block
?> @block.yield(@val)
?> end
>> end
=> :run_block
>>
>> foo = Foo.new("I love ruby!") { |e| puts "블록 #{e}" }
=> #<Foo:0x00000141fa4c0ee0 @val="I love ruby!", @block=#<Proc:0x00000141fa4c0eb8 (irb):11>>
>> foo.run_block
블록 I love ruby!
=> nil
이제 블록을 사용하는 방법을 알았으니 메뉴 처리 기능에서도 블록을 사용할 수 있게 코드를 수정해 보자.
메뉴 처리 관련 코드 중 Menu 클래스 코드만 수정하면 될 것 같다. 아래 코드를 보면 기존 코드에서 달라진 것은 initialize 메서드에서 블록을 파라미터로 받아 인스턴스 변수에 저장하고 메뉴 선택시 실행되는 select 메서드에서 객체 생성 시 넘겨 받은 블록이 있다면 블록을 실행하고 그렇지 않으면 기본 처리를 하도록 한 것이다.
class Menu
attr_reader :name
attr_accessor :parent_menu
def initialize(name, &block)
@name = name
@block = block
end
def select
if @block
@block.yield
else
puts "#{name} 메뉴를 선택하셨습니다."
end
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
코드를 변경했으니 생각한 대로 잘 동작하는지 테스트를 진행해 보자.
>> require './menu'
=> true
>> menu = Menu.new("test")
=> #<Menu:0x000002910a1eaf30 @name="test", @block=nil>
>> menu.select
test 메뉴를 선택하셨습니다.
=> nil
>> menu = Menu.new("test") { puts "블록!" }
=> #<Menu:0x0000029109de8b58 @name="test", @block=#<Proc:0x0000029109de8a18 (irb):4>>
>> menu.select
블록!
=> nil
위의 테스트 결과를 보면 블록을 주지 않고 메뉴 객체를 생성한 후 select 메서드를 호출하면 기존 처럼 화면에 'test 메뉴를 선택하셨습니다.' 가 출력되고 블록을 주면서 메뉴 객체를 생성한 후 select 메서드를 호출하면 블록 안의 내용이 실행되는 것을 볼 수 있다.
이제 본격적으로 메뉴 처리 기능을 이용하여 실제 스피드 연산 게임의 메뉴를 구성해 보자.
메뉴 구성 관련 코드는 speed_quiz.rb 파일과 별도로 game_menu.rb 파일을 만들어 작성하자. 하나의 메서드, 클래스, 소스 파일 등이 너무 복잡해지지 않도록 잘 분리하는 것도 코드 작성 못지않게 중요하다.
아래 game_menu.rb 코드가 있다. 메뉴 구성을 책임질 GameMenu 클래스를 정의했는데 '문제 유형 선택' 이나 '게임 시간 설정' 등 메뉴 기능 처리를 위해서는 게임 객체가 필요하므로 GameMenu 객체 생성 시 게임 객체를 인수로 받도록 했다.
그리고 실제 메뉴 구성 작업은 'build' 메서드에서 이루어지는데 아직까지는 단순히 메뉴 구성만 하고 있을 뿐 블록을 사용한 실제 기능 처리는 작성하지 않았다.
require './menu'
class GameMenu
def initialize(game)
build(game)
end
def build(game)
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)
@menu = MenuList.new("게임 메뉴")
@menu.add(ExitMenu.new("바로 게임 시작"))
@menu.add(quiz_menu)
@menu.add(Menu.new("게임 시간 설정"))
end
def show
@menu.select
end
private :build
end
>> require './game_menu'
=> true
>> GameMenu.new(nil).show
[ 게임 메뉴 ]
1. 바로 게임 시작
2. 문제 유형 선택
3. 게임 시간 설정
선택 > 2
[ 문제 유형 선택 ]
1. 구구단
2. 덧셈
3. 뺄셈
4. 나눗셈
5. 객관식 문제 선택
6. 상위 메뉴로 가기
선택 > 5
[ 객관식 문제 선택 ]
1. 루비문제
2. 영어문제
3. 상위 메뉴로 가기
선택 > 1
루비문제 메뉴를 선택하셨습니다.
[ 객관식 문제 선택 ]
1. 루비문제
2. 영어문제
3. 상위 메뉴로 가기
선택 > 3
[ 문제 유형 선택 ]
1. 구구단
2. 덧셈
3. 뺄셈
4. 나눗셈
5. 객관식 문제 선택
6. 상위 메뉴로 가기
선택 >
위 그림에서 보이는 것처럼 GameMenu 클래스의 테스트까지 마쳤으니 블록을 사용하여 실제 메뉴 기능 처리를 작성해 보자. 위의 테스트에서는 아직까지 build 에서 게임 객체를 사용하지 않기 때문에 GameMenu 객체 생성 시 그냥 nil 을 인수로 주었다.
require './menu'
class GameMenu
def initialize(game)
build(game)
end
def build(game)
choice_quiz_menu = MenuList.new("객관식 문제 선택")
choice_quiz_menu.add(Menu.new("루비문제") { game.quiz = ChoiceQuiz.new("./workbook/루비문제.txt") })
choice_quiz_menu.add(Menu.new("영어문제") { game.quiz = ChoiceQuiz.new("./workbook/영어문제.txt") })
quiz_menu = MenuList.new("문제 유형 선택")
quiz_menu.add(Menu.new("구구단") { game.quiz = Gugudan.new })
quiz_menu.add(Menu.new("덧셈") { game.quiz = Add.new })
quiz_menu.add(Menu.new("뺄셈") { game.quiz = Sub.new })
quiz_menu.add(Menu.new("나눗셈") { game.quiz = Div.new })
quiz_menu.add(choice_quiz_menu)
@menu = MenuList.new("게임 메뉴")
@menu.add(ExitMenu.new("바로 게임 시작"))
@menu.add(quiz_menu)
@menu.add(Menu.new("게임 시간 설정") { set_time(game) })
end
def set_time(game)
while true
print "5 ~ 15 사이를 입력해 주세요 > "
input = STDIN.gets.to_i
if input >= 5 && input <= 15
game.time = input
break
end
end
end
def show
@menu.select
end
private :build, :set_time
end
위의 코드를 보면 메뉴 객체 생성 시 넘겨준 블록의 내용이 최초의 메뉴 처리 코드에서 보았던 것과 동일한 것을 알 수 있다. 기존 글의 speed_quiz.rb 소스에서 seq_quiz 메서드나 set_choice_quiz 메서드를 보면 특정 문제 유형을 선택했을 때 game 객체의 setter 메서드를 통해 그 문제(객체)를 설정하는 코드가 있는데 그 코드가 블록 안으로 들어온 것이다.
그리고 '게임 시간 설정' 메뉴의 경우 관련 코드를 모두 블록 안에 넣을 수 있지만 코드가 복잡해지지 않도록 set_time 메서드로 해당 기능에 대한 코드를 정의하고 그 메서드를 블록 안에서 호출하도록 하였다. 이 set_time 메서드는 최초의 메뉴 처리 코드에 있는 것을 그대로 사용해도 되지만 재귀 호출을 반복문으로 변경하고 시간 범위를 조정해 보았다.
블록을 사용한 메뉴 기능 처리 코드를 넣었더니 build 메서드가 조금 복잡해졌다. 퀴즈 객체를 생성하는 코드를 별도로 분리해서 조금 더 정리를 해도 좋을 것 같은데 이 부분은 여러분에게 숙제로 남겨두겠다.
이제 이 game_menu.rb 소스를 사용하여 정리한 speed_quiz.rb 소스의 최종 모습을 보자.
require './game'
require './quiz'
require './choice_quiz'
require './game_menu'
game = Game.new
menu = GameMenu.new(game)
while true
menu.show
game.play
print "게임을 다시 하시겠습니까? (y/n) "
input = STDIN.gets.chomp
break if input != "y"
end
이 소스에서 메뉴 처리와 관련된 세부 내용의 코드가 모두 제거가 되니 코드가 한결 깜끔하고 보기 좋아졌다.
어떠한 일을 처리하는 코드인지 명확해졌다. 항상 코드를 더 작게 나누는 것이 정답은 아니고 처음부터 꼭 그럴 필요도 없다. 오히려 처음에는 기능 구현 자체에 집중을 하다가 어느순간 코드가 복잡하다는 생각이들면 그때 코드를 더 작게 나눌 방법을 고민해 보는 것이 좋을 수 있다.
'스피드 연산 게임 만들기' 시리즈가 오늘의 글을 끝으로 드디어 마무리가 되었다. 다음 글에서는 메뉴 처리 코드를 재사용해 볼 수 있는 또 다른 프로그램을 만들어 보려고 한다.
See you again~~