이번에는 연산 문제는 아니지만 객관식 문제를 풀어볼 수 있게 프로그램을 수정해 보자.
우선 5개의 객관식 문제를 아래 처럼 작성하여 game.rb 파일과 같은 위치에 루비문제.txt 파일로 저장하였다.
파일에 작성된 내용을 읽어와 문제를 보여주고 답을 맞춰보기 위해서는 파일에 문제와 정답을 작성할 때 일정한 규칙을 갖고 작성을 해야 한다.
위의 내용을 보면 문제와 문제 사이에는 빈 라인을 하나씩 주었고 정답은 각 문제의 마지막 줄에 '> ' 와 함께 적었다.
이제 위의 문제 파일을 읽어와 문제와 정답 두 요소가 담긴 배열을 반환하는 make 메서드가 있는 클래스를 만들기만 하면 이미 만들어 두었던 Game 클래스를 그대로 사용하여 객관식 문제를 풀 수 있을 것이다.
아래 처럼 ChoiceQuiz 라는 클래스를 만들었다.
class ChoiceQuiz
def initialize(file)
parse(file)
end
def parse(file)
@quiz = []
str = File.read(file)
arr = str.split(/^$/)
arr.each do |e|
question, answer = e.split("> ")
@quiz.push([question, answer.to_i])
end
end
def make
@quiz[rand(@quiz.size)]
end
end
코드를 보면 initialize 메서드를 통해 문제 파일(이름 포함 경로)을 받고 parse 메서드에서는 해당 파일의 내용을 위에서 얘기했던 작성 규칙대로 파싱하여 [문제, 답] 의 배열을 만들고 있다.
파일에서 전체 내용을 문자열로 한 번에 읽어오기 위해 File 클래스의 read 메서드를 이용했고 빈 라인을 기준으로 문자열을 나누기 위해 split 메서드에 빈 라인을 뜻하는 정규 표현식(Regular expression)을 넘겨 주었다. 루비에서 정규 표현식도 Regexp 라는 클래스의 객체인데 리터럴 방식으로 '/' 와 '/' 사이에 작성하여 생성할 수 있다. 정규 표현식 /^$/ 에서 '^' 은 문자열의 시작을 의미하고 '$' 은 문자열의 끝을 의미한다. 즉 문자열의 시작과 끝이 그 사이에 어떠한 문자도 없이 바로 만난다는 것은 빈 라인을 의미하게 되는 것이다. 정규 표현식에 대해서는 필요할 때마다 조금씩 설명하도록 하겠다.
어쨌든 '루비문제.txt' 파일의 전체 내용을 빈 라인을 구분자로 하여 나누게 되면 5 개의 문자열이 되고 각 문자열에는 하나의 문제와 답이 포함되어 있다. 여기서 다시 각 문자열을 "> " 로 나누게 되면 우리가 원하는 [문제, 답] 배열을 얻게 된다.
[문제, 답] 배열은 배열의 push 메서드를 사용하여 @quiz 가 참조하는 배열에 하나씩 밀어 넣었다.
배열을 요소로 갖는 배열을 '중첩 배열' 또는 '다차원 배열'이라고 하는데 특히 @quiz 처럼 배열 안에 배열이 한 번만 중첩해서 나오는 경우를 2차원 배열이라고 부른다.
이제 ChoiceQuiz 클래스를 irb 에서 테스트해 보자.
>> require './choice_quiz'
=> true
>> quiz = ChoiceQuiz.new("./루비문제.txt")
=> #<ChoiceQuiz:0x00000199f97fc0a8 @quiz=[["배열에서 첫 번째 요소를 제거하고 그 요소를 반환하는...
>> quiz.make
=> ["\n다음 중 데이터를 키와 값의 쌍으로 저장하려고 할때 사용하기 적합한 클래스는?\n① String\n② Array\n③ Hash\n④ Range\n", 3]
>> quiz.make
=> ["\n1...5 는 Range 객체인데 이것이 나타내는 범위의 최대값은 무엇인가?\n① 1\n② 3\n③ 4\n④ 5\n", 3]
>> quiz.make
=> ["\n1...5 는 Range 객체인데 이것이 나타내는 범위의 최대값은 무엇인가?\n① 1\n② 3\n③ 4\n④ 5\n", 3]
>> quiz.make
=> ["\na, *b = [1, 2, 3] 을 실행할 경우 변수 b 에 할당되는 것은?\n① nil\n② 1\n③ 2\n④ [2, 3]\n", 4]
>> quiz.make
=> ["배열에서 첫 번째 요소를 제거하고 그 요소를 반환하는 메서드 이름은?\n① size\n② each\n③ shift\n④ sort\n", 3]
코드를 심플하게 하기 위해 @quiz 에서 문제와 답을 하나 선택할 때 랜덤을 사용했는데 그림에서 보이는 것처럼 한번의 게임 안에서 같은 문제가 중복해서 나올 수 있다. 그렇다면 롤링방식으로 문제를 보여주도록 코드를 수정해 보자.
이 방법은 make 메서드가 직전에 반환한 [문제, 답]의 인덱스를 저장할 인스턴스 변수가 하나 필요하다.
그리고 랜덤 효과를 주기 위해 처음 파일에서 문제를 읽어온 후 한번 섞기로 하자. 아래 코드가 방금 얘기한 내용을 반영한 코드이다.
우선 parse 메서드를 보면 빈 라인을 구분자로 하여 split 한 결과를 바로 arr 변수에 담지 않고 shuffle 메서드를 이어서 호출한 것이 보인다. 이처럼 메서드의 결과에 대해 바로 이어서 메서드를 호출하는 것을 '메서드 체이닝(Method Chaining)'이라고 한다. 영어사전을 보면 shuffle은 '(게임 전에 카드를) 섞기' 라고 나오는데 말 그대로 배열의 요소를 한번 섞은 후에 돌려준다. 이때 원래 배열 자체를 섞지는 않고 요소의 순서만 섞인 새 배열을 만들어 돌려준다.
그리고 make 메서드를 보면 @idx 인스턴스 변수의 값을 이용하여 문제를 롤링 방식으로 선택하는 게 보인다.
즉 인덱스 0 인 배열의 요소부터 시작하여 배열의 마지막 요소까지 가게 되면 다시 인덱스 0 으로 되돌아 가도록 초기화하고 있다. @idx 값이 배열의 끝을 넘어섰는지 검사하고 그럴 경우 초기화하기 위해 if 문을 사용했는데 보이는 것처럼 if문을 한 줄로 작성할 수도 있다.
class ChoiceQuiz
def initialize(file)
parse(file)
@idx = 0
end
def parse(file)
@quiz = []
str = File.read(file)
arr = str.split(/^$/).shuffle
arr.each do |e|
question, answer = e.split("> ")
@quiz.push([question, answer.to_i])
end
end
def make
@idx = 0 if @idx == @quiz.size
quiz = @quiz[@idx]
@idx += 1
return quiz
end
private :parse
end
irb 에서 방금 수정한 코드를 실행해 봤다.
>> require './choice_quiz'
=> true
>> quiz = ChoiceQuiz.new("./루비문제.txt")
=> #<ChoiceQuiz:0x0000023bc193f608 @quiz=[["\n클래스 정의 시 특정 속성에 대한 getter를 자동으로...
>> quiz.make
=> ["\n클래스 정의 시 특정 속성에 대한 getter를 자동으로 생성해 주는 클래스 메서드 이름은?\n① attr_reader\n② attr_writer\n③ attr_accessor\n④ private\n", 1]
>> quiz.make
=> ["\n다음 중 데이터를 키와 값의 쌍으로 저장하려고 할때 사용하기 적합한 클래스는?\n① String\n② Array\n③ Hash\n④ Range\n", 3]
>> quiz.make
=> ["\na, *b = [1, 2, 3] 을 실행할 경우 변수 b 에 할당되는 것은?\n① nil\n② 1\n③ 2\n④ [2, 3]\n", 4]
>> quiz.make
=> ["\n1...5 는 Range 객체인데 이것이 나타내는 범위의 최대값은 무엇인가?\n① 1\n② 3\n③ 4\n④ 5\n", 3]
>> quiz.make
=> ["배열에서 첫 번째 요소를 제거하고 그 요소를 반환하는 메서드 이름은?\n① size\n② each\n③ shift\n④ sort\n", 3]
>> quiz.make
=> ["\n클래스 정의 시 특정 속성에 대한 getter를 자동으로 생성해 주는 클래스 메서드 이름은?\n① attr_reader\n② attr_writer\n③ attr_accessor\n④ private\n", 1]
>> quiz.make
=> ["\n다음 중 데이터를 키와 값의 쌍으로 저장하려고 할때 사용하기 적합한 클래스는?\n① String\n② Array\n③ Hash\n④ Range\n", 3]
>>
실행 결과를 보면 알겠지만, 이 방법 역시 전체 문제 개수가 게임 한번을 진행할 정도로 충분하지 않다면 같은 문제가 중복해서 나오게 된다. 문제 개수가 적어 오히려 롤링이 잘 동작하는 것을 보게 되었고 이건 문제 개수만 좀 늘려주면 해결이 될 문제이니 이제는 실제로 ChoiceQuiz 를 기존에 만든 게임 프로그램에서 사용해 보자.
아래 그림과 같이 irb에서 테스트하기 위해 game.rb 와 choice_quiz.rb 두 파일을 로드했다. 그리고 먼저 문제가 담긴 파일의 경로를 인수로 하여 ChoiceQuiz 객체를 생성했다. 이어서 앞에서 생성한 ChoiceQuiz 객체를 인수로 하여 Game 객체를 생성한 후 게임을 플레이했다.
기존에 만들어 놓은 game.rb 의 소스를 하나도 수정하지 않고도 객관식 문제를 풀 수 있게 되었다.
또한 루비 프로그래밍에 대한 문제만이 아니라 다른 어떤 문제라도 작성 규칙만 지켜 문제 파일을 작성한다면 프로그램 수정없이 해당 문제 파일의 문제를 스피드 게임을 통해 풀어볼 수 있다.
>> require './game'
=> true
>> require './choice_quiz'
=> true
>>
>> quiz = ChoiceQuiz.new("./루비문제.txt")
=> #<ChoiceQuiz:0x000001c0358fc9c8 @quiz=[["\n1...5 는 Range 객체인데 이것이 나타내는 범위의 최...
>> game = Game.new(quiz)
=> #<Game:0x000001c03582c908 @quiz=#<ChoiceQuiz:0x000001c0358fc9c8 @quiz=[["\n1...5 는 Range 객...
>> game.play
1...5 는 Range 객체인데 이것이 나타내는 범위의 최대값은 무엇인가?
① 1
② 3
③ 4
④ 5
3
정답
배열에서 첫 번째 요소를 제거하고 그 요소를 반환하는 메서드 이름은?
① size
② each
③ shift
④ sort
총 2문제 중 1문제를 맞췄습니다.
=> nil
지금까지 스피드 연산 게임을 만들어 보았다
여러분이 나눗셈 문제도 풀어볼 수 있도록 프로그램을 개선해 보면 좋을 거 같다. 만약 나눗셈 문제 중 결과값에 소숫점이 있는 문제도 포함시키려 한다면 정답 여부를 판별하는 코드도 수정해야 한다. 그런데 그 코드는 구구단, 덧셈, 뺄셈, 객관식 등의 문제에서도 공통으로 사용하는 부분이기 때문에 코드를 수정했다면 기존 문제 유형들에 대해서도 다시 꼼꼼한 테스트가 필요하다.
See you again~~