루비로 시작하는 프로그래밍

일반인을 위해 프로그래밍을 가르쳐주는 튜토리얼.

10장. 블록과 프록

Blocks and Procs

블록은 뭐고 프록은 뭘까요?

todo : 메소드 -> 메서드로 바꾸기

이번에 살펴볼 기능은요, 루비의 멋진 기능중에서도 정말 좋은 기능이에요. 다른 프로그래밍 언어에도 이 기능이 있는 경우가 있는데요, 이름은 다를 수도 있어요. 예를 들면 closure(한국어표현?)가 있죠. 하지만 인기가 많은 프로그래밍 언어에는 대부분 이 기능이 없어요. 안타까운 일이죠. 이 기능이 대체 무슨 기능이냐고요? 어떤 거냐면요, do와 end사이에 있는 코드를 가지고 하나의 객체로 만드는 거예요. 이렇게 만들어진 객체를 프록이라고 하고요. 그리고 이 프록을 하나의 변수에 넣어서 메소드에 넘겨주는 거지요. 그리고 이 코드블럭 부분을 원할 때마다 실행시키는 거예요. 원하면 여러 번 실행시킬 수도 있죠. 결국 프록이란 건 메소드와 비슷하긴 한데요, 차이점은 바로 객체에 묶여 있다는 것이에요. 아니, 프록은 객체죠. 객체는 변수에 저장하고, 메소드에 넘겨줄 수 있잖아요? 이처럼, 프록도 변수에 넣어서 메소드에 넘겨줄 수 있어요. 예를 통해서 이해하는 게 좋을 것 같네요.

toast = Proc.new do
  puts 'Cheers!'
end

#실행결과

toast.call
toast.call
toast.call
Cheers!
Cheers!
Cheers!

위의 예제에서는 프록을 만들어봤습니다. 제 생각에 프록(proc)은 프로시져(procedure)를 줄여서 부르는 것 같아요. 하지만 더 중요한 건 블록(block)과 운율이 맞는다는 점이에요. 프'록', 블'록'. 여기서 블록이란 do와 end 사이에 있는 코드 모음을 의미해요. 여하튼, 프록을 만들었고요, 이 프록을 세 번 호출했어요. 정말 메소드하고 비슷하죠?

실은 프록은 정말 메소드와 비슷해요. 메소드처럼 인자를 넘겨받을 수 있거든요:

doYouLike = Proc.new do |aGoodThing|
  puts 'I *really* like '+aGoodThing+'!'
end

doYouLike.call 'chocolate'
doYouLike.call 'ruby'
I *really* like chocolate!
I *really* like ruby!
puts -

#실행결과

- '정말' 좋아!

초콜렛
루비

자, 블록과 프록이 뭔지, 어떻게 사용하는지 배워봤습니다. 그런데 대체 이런 것을 왜 쓰는 걸까요? 메소드와 비슷하다면, 그냥 메소드를 쓰면 되지 않나요? 이유가 있습니다. 메소드로는 할 수 없는 것들이 있거든요. 특히 메소드에 메소드를 인자로 넘겨줄 수는 없죠. 그런데 프록은 메소드에 넘겨줄 수 있습니다. 그리고 메소드는 메소드를 반환할 수 없죠. 하지만 메소드는 프록을 반환할 수 있죠. 어째서 이게 가능할까요? 프록은 객체이기 때문이죠. 메소드는 객체가 아니고요. (그나저나, 프록이란 것, 익숙하지 않나요? 맞습니다. 여러분은 프록을 전에 접하신 적이 있어요. iterator에 대해서 배웠을 때 접했죠. 이 부분에 대해서 좀 더 얘기 나눠보기로 하죠.)

프록은 인자로 메서드를 넘겨받을 수 있어요.

메소드에 프록을 인자로 넘겨줄 때는요, 이 프록을 호출할지 여부나 몇 번 호출할지를 원하는데로 조절할 수 있답니다. 예를 들어, 어떤 코드가 실행되기 전 후에 몇 줄의 코드를 실행하고 싶은 상황이 있다고 해보지요.
 

def doSelfImportantly someProc
  puts 'Everybody just HOLD ON!  I have something to do...'
  someProc.call
  puts 'Ok everyone, I\'m done.  Go on with what you were doing.'
end

sayHello = Proc.new do
  puts 'hello'
end

sayGoodbye = Proc.new do
  puts 'goodbye'
end

#실행결과

doSelfImportantly sayHello
doSelfImportantly sayGoodbye
Everybody just HOLD ON!  I have something to do...
hello
Ok everyone, I'm done.  Go on with what you were doing.
Everybody just HOLD ON!  I have something to do...
goodbye
Ok everyone, I'm done.  Go on with what you were doing.

'잠깐 좀 기다려요! 할 게 있다고요..'

'좋아요. 이제 됐어요. 하던 것을 계속 하세요.'

네? 별로 그렇게 특별히 멋지는 않은 것 같다고요? 하지만 멋지답니다 :-) 프로그래밍에서는요, 어떤 경우에 이런 작업들을 꼭 해줘야 한다는 규칙들이 매우 흔하거든요. 예를 들어 파일을 저장하는 경우를 생각해 보지요. 우선은 파일을 열어야 하고요, 파일에 기록하고 싶은 내용을 쓰고요, 그리고 파일을 닫습니다. 만약 파일을 닫는 작업을 잊어버리면, 안 좋은 일이 생긴답니다. 매번 파일을 저장하거나 불러올 때에는 세 단계의 작업을 매번 해줘야 하죠. 1) 파일 열기 2) 기록하고 싶은 것을 기록하기 3) 파일을 닫기. 이중에 특히 3)번은 지루하고 잊어버리기 쉬운 작업이죠.

루비에서는요, 파일을 저장하거나 불러오는 과정이 위에서 예로든 코드가 작동하는 과정과 비슷합니다. 그래서 저장하거나 불러올 때, 실제로 하고 싶은 작업, 예를 들면 어떤 내용을 파일에 기록하는 작업 외에는 걱정할 필요가 없죠. (11장에서는 파일을 저장하거나 불러올 때 필요한 것들을 어디서 찾을 수 있는지, 어떻게 사용하는지 알려드릴게요.)

그리고, 프록을 몇 번 실행할지, 또는 프록을 실행을 할지말지 여부를 메소드를 작성할 때 미리 정할 수 있어요. 아래에서 살펴볼 한 가지예에서는요, 넘겨받은 프록을 50%의 확률로 실행시킬 거예요. 그리고 그 다음 예에서는 넘겨받은 프록을 두 번 실행시킬 거예요.

def maybeDo someProc
  if rand(2) == 0
    someProc.call
  end
end

def twiceDo someProc
  someProc.call
  someProc.call
end

wink = Proc.new do
  puts '<wink>'
end

glance = Proc.new do
  puts '<glance>'
end

#실행결과

maybeDo wink
maybeDo glance
twiceDo wink
twiceDo glance
<glance>
<wink>
<wink>
<glance>
<glance>

프록은 언제 사용할까?

프록을 사용하는 경우가 좀 더 있습니다. 메소드만으로는 원하는 것을 구현할 수 없을 때 프록을 사용하는데요. 물론 두번 윙크하라는 메소드는 만들 수 있습니다. 그렇지만 무언가를 두번 하라는 메소드는 만들 수 없죠.

다음 부분으로 넘어가기 전에 마지막 예제를 살펴봅시다. 이제까지는 우리가 메소드에 넘겨주었던 프록들이 서로 비슷했었어요. 이번에는 서로 다른 프록들을 넘겨줘 봅시다. 이렇게 해보면 이런 메소드가 넘겨받은 프록에 따라 얼마나 달라지는지 알 수 있을 거예요. 이번에는 메소드를 만드는데요, 몇몇 객체와 프록을 인자로 넘겨받을 겁니다. 그리고 그 객체에 대해서 프록을 호출할 거예요. 프록이 거짓을 반환하면 종료하고요, 참을 반환하면 반환된 객체를 가지고 프록을 호출하는 거예요.

프록이 거짓을 반환할 때까지 이 작업을 계속해서 수행할 거예요. (언젠가 프록은 거짓을 반환하는 편이 나아요. 안 그러면 프로그램이 먹통이 될테니까요.) 이 메소드는 프록이 반환한 마지막 값, 0이 아닌 값을 반환할 거예요.

def doUntilFalse firstInput, someProc
  input  = firstInput
  output = firstInput
 
  while output
    input  = output
    output = someProc.call input
  end
 
  input
end

buildArrayOfSquares = Proc.new do |array|
  lastNumber = array.last
  if lastNumber <= 0
    false
  else
    array.pop                         #  Take off the last number...  마지막 번호를 가져옴
    array.push lastNumber*lastNumber  #  ...and replace it with its square... 그 번호를 제곱한 값으로 교체함
    array.push lastNumber-1           #  ...followed by the next smaller number.  -
  end
end

alwaysFalse = Proc.new do |justIgnoreMe|
  false
end

puts doUntilFalse([5], buildArrayOfSquares).inspect
puts doUntilFalse('I\'m writing this at 3:00 am; someone knock me out!', alwaysFalse)

#실행결과

[25, 16, 9, 4, 1, 0]
I'm writing this at 3:00 am; someone knock me out!

흠, 좀 이상한 예였나요? 네네, 인정할게요. 그래도 이 예를 보면 넘겨받은 프록에 따라서 메소드들이 얼마나 다르게 작동하는지는 볼 수 있잖아요? inspect 메소드는 to_s 메소드와 비슷한데요, 차이점이 있다면, inspect 메소드가 반환하는 문자열이 여러분에게 원래 it에게 넘겨주었던( it이 가리키는 것은??) 객체를 만드는데 필요한 루비코드를 알려주려고 한다는 점이에요.

여기에서는 첫번째 doUntilFalse에서 반환한 배열 전체를 볼 수 있습니다.  그리고 한번도 배열의 제일 마지막 값인 0을 제곱하지 않았다는 것을 알아내셨을 거예요. 그리고 0을 제곱하더라도 결국 0이 되니, 어차피 제곱을 할 필요가 없었죠. 그리고 alwaysFalse가 항상 거짓이었기 때문에, 두번째에서 doUntilFalse를 호출했을 때, doUntilFalse는 아무것도 하지 않았죠. 자기가 넘겨받은 것을 반환하기만 했고요.

프록은 메서드를 반환할 수 있어요.

프록을 가지고 할 수 있는 것 중에 또 멋진 것이 있는데요, 바로 메소드 안에서 프록을 만들어내고, 프록을 반환할 수 있다는 거예요. 이렇게 하면 온갖 종류의 신기한 프로그래밍을 할 수 있죠. (이를테면 lazy evaluation이나 무한대의 자료 구조를 갖는 신기한 이름을 가진 것들을 만든다든지요.)

하지만 제 경우에는 실제 프로그래밍을 할 때 이렇게 사용하지 않습니다. 그리고 주변 분들에게서도 코드짤 때 이렇게 하는 경우를 본 적이 없어요. 지금 기억하는 한에서는요. 제 생각에는요, 여러분이 루비로 프로그래밍을 하면서 이런 방법을 사용하게 될 것이라고는 생각하지 않습니다. 루비는 여러분이 다른 방식을 찾는 쪽으로 유도하죠. 어쨌든 이 부분에 대해서는 간략하게 다루겠습니다.

아래의 예에서 compose메소드는 두 개의 프록, 프록1과 프록2를 인자로 넘겨받고, 새로운 프록, 프록3을 반환합니다. 프록3을 호출하면, 프록1을 호출하고요, 프록1이 반환한 값을 프록2에게 넘겨줍니다.

def compose proc1, proc2
  Proc.new do |x|
    proc2.call(proc1.call(x))
  end
end

squareIt = Proc.new do |x|
  x * x
end

doubleIt = Proc.new do |x|
  x + x
end

doubleThenSquare = compose doubleIt, squareIt
squareThenDouble = compose squareIt, doubleIt

puts doubleThenSquare.call(5)
puts squareThenDouble.call(5)

#실행결과

100
50

아, 이 부분을 눈여겨 보세요.. 프록2보다 프로1을 먼저 호출하려면 프록1을 괄호 안에 넣어야 합니다.

메서드에 프록이 아니라, 블록을 넘겨주기

문제는 뭐냐면요, 이처럼 사용하려면, 메소드를 정의하고, 프록을 만들고, 프록을 인자로 넘겨주면서 메소드를 호출하는 세 단계를 거쳐야 한다는 거예요. 언뜻 생각하면, 메소드를 정의하고, 프록을 사용하지 않은 채 블록을 바로 메소드에 넘겨주는 두 단계만 필요할 것 같거든요. 왜냐하면 프록/블록을 한 번 메소드에 넘겨준 다음에는 대부분 프록/블록을 다시 사용하지 않기 때문이죠.

그런데 말이죠, 우리의 루비는 이것에 대해 다 대비를 해두었답니다. 사실 여러분이 반복자(iterator)를 사용했을 때, 이미 다 사용했던 거예요. 우선 간단한 예를 보여 드릴게요. 그 다음에 설명드리기로 하죠.

class Array
 
  def eachEven(&wasABlock_nowAProc)
    isEven = true  #  We start with "true" because arrays start with 0, which is even.
    
    self.each do |object|
      if isEven
        wasABlock_nowAProc.call object
      end
      
      isEven = (not isEven)  #  Toggle from even to odd, or odd to even. 짝수이면 홀수로, 홀수이면 짝수로 토글하기/ 값을 변경하기
    end
  end

end

['apple', 'bad apple', 'cherry', 'durian'].eachEven do |fruit|
  puts 'Yum!  I just love '+fruit+' pies, don\'t you?'
end

#  Remember, we are getting the even-numbered elements
#  of the array, all of which happen to be odd numbers,
#  just because I like to cause problems like that.


#실행결과

[1, 2, 3, 4, 5].eachEven do |oddBall|
  puts oddBall.to_s+' is NOT an even number!'
end
Yum!  I just love apple pies, don't you?
Yum!  I just love cherry pies, don't you?
1 is NOT an even number!
3 is NOT an even number!
5 is NOT an even number!

eachEven에 블록을 넘겨주려면...

그래서 eachEven에 블록을 넘겨주려면, 메소드 뒤에 블록을 붙이기만 하면 됩니다. 이렇게 하면 메소드에 블록을 넘겨줄 수 있어요. 그런데 많은 경우, 메소드는 블록을 그냥 무시합니다. 메소드가 블록을 무시하지 못하게 하려면요, 블록을 묶어서 프록으로 만드세요. 그리고 프록의 이름앞에 &(앰퍼샌드)를 붙여서 메소드에 넘기는 인자목록 제일 뒤에 넣으세요. 이 부분은 좀 헷갈릴 수도 있는데요, 그렇게 어렵지는 않아요. 그리고 한번만 이렇게 쓰면 되요. 메소드를 정의할 때요. 그러면 메소드를 계속해서 사용할 수 있죠. each메소드나 times메소드처럼 블록을 인자로 넘겨받는 빌트인 메소드처럼요. ( 5.times do .. 이 내용 생각나시나요?)

헷갈리시다고요? 그냥 이렇게 기억하세요. eachEven메소드를 호출하면 넘겨받은 블록을 배열 내의 모든 인자에 대해서 호출합니다. 한 번 작성해서, 제대로 작동하면, 실제로 어떤일이 일어나는지 굳이 생각하지 않아도 되요. (언제 어느 블록이 호출되는가? 등등의 질문을 던지지 않아도 됩니다.). 사실 바로 이런 이유 때문에 이런 메소드를 쓰는 거거든요. 이런 메소드를 쓰면 이들이 어떤 방식으로 작동하는지 절대 생각하지 않죠. 그냥 이들을 사용하죠.

한번은 이런 적이 있었어요. 프로그램 코드의 일 부분에서 걸리는 시간이 얼마나 되는지 재보고 싶었습니다. 이런 것을 코드를 asprofiling(??  as profiling 프로파일링이라고도 부릅니다. 일수도..)이라고도 합니다. 그래서 메소드를 만들었습니다. 이 메소드는 그 코드를 실행하기 전과 후 각각에 시각을 재고, 그 차이를 계산합니다. 그 코드는 못 찾겠는데요, 괜찮아요, 다시 쓰면 되죠. 대략 아래와 같았을 겁니다:

def profile descriptionOfBlock, &block
  startTime = Time.now
 
  block.call
 
  duration = Time.now - startTime
 
  puts descriptionOfBlock+':  '+duration.to_s+' seconds'
end

profile '25000 doublings' do
  number = 1
 
  25000.times do
    number = number + number
  end
 
  puts number.to_s.length.to_s+' digits'  #  That is, the number of digits in this HUGE number.
end

# digit은 이 큰 수에 들어있는 숫자의 개수를 의미합니다.

profile 'count to a million' do
  number = 0
 
  1000000.times do
    number = number + 1
  end
end

#실행결과

7526 digits
25000 doublings:  0.246768 seconds
count to a million:  0.90245 seconds

정말 쉽죠! 게다가 우아하고요! 이런 작은 메소드만 가지고, 프로그램의 어느 부분이든 원하기만 하면 걸리는 시간을 쉽게 잴 수 있게 되었습니다.  코드를 블록으로 만들고, 이 블록을 profile메소드에게 넘겨주기만 하면 되거든요. 이보다 더 쉬운 방법이 있을까요? 대부분의 프로그래밍 언어에서는요, 시간을 재는 코드 부분을, 시간을 재려는 코드 앞뒤에 모두 써넣어줘야 했을 겁니다. 그렇지만 루비에서는요, 한 군데에서 다 모아둘 수 있죠. 그리고 더 중요한 건 저를 방해하지 않는다는 거예요.

프로그램 만들어 보기

괘종 시계

블록을 인자로 넘겨받는 메소드를 만들어 보세요. 그리고 몇 시 정각이 될 때마다 메소드를 호출해 보세요. 블록에다가 do puts '땡' end를 넘겨주면, 마치 괘종 시계처럼 매 시각마다 알려줄 거예요. 몇 종류의 블록으로 여러분이 만든 메소드를 테스트해 보세요. (아까 제가 드린 것, do puts '땡' end 도 포함해서요.)

힌트 : 현재 시각의 '시(hour)'을 구할 때 Time.how.hour를 사용하면 되요. 하지만 이 메소드는 0에서 23까지의 '시'를 반환합니다. 그러니 이를 1에서 12 사이의 숫자가 되도록 바꿔주어야 해요.
 

프로그램 일지

 log라는 메소드를 만드세요. 이 메소드는 블록과 블록에 대한 설명인 문자열을 인자로 받습니다. doSelfImportantly 메소드와 비슷하게, 이 메소드는 블록이 시작된다는 내용의 문자열을 출력합니다. 그리고 끝에는 블록이 끝난다는 내용을 문자열로 출력하고요. 블록이 반환하는 것도 문자열로 출력해줘야 합니다. 메소드에 코드 블록을 넘겨서 메소드를 테스트해 보세요. 넘겨주는 블록 안에다가는 log 메소드를 호출하는 부분을 넣어주세요. 이 때, 또 하나의 블록을 인자로 넘겨주면서 log 메소드를 호출해주세요. (이렇게 하는 것을 'nesting'이라고 한답니다.) 결국 이 코드를 실행하면 다음과 같은 결과가 화면에 나옵니다. (요 부분은 확인하기)

Beginning "outer block"...
Beginning "some little block"...
..."some little block" finished, returning:  5
Beginning "yet another block"...
..."yet another block" finished, returning:  I like Thai food!
..."outer block" finished, returning:  false

더 좋은 일지기록 프로그램

위에서 만든 일지의 결과물은 읽기가 좀 불편하죠. 그리고 이 일지는 프로그램을 사용하면 할 수록 더 읽기 불편해 질 거예요. 내부의 블록과 관련된 줄에 들여쓰기를 한다면 훨씬 읽기가 쉬울 거예요. 이렇게 하려면 이 일지기록 프로그램이 뭔가를 쓸 대마다 몇 단계만큼 들어가 있는지 체크하고 있어야 해요. 그리고 이 값은 코드의 어느 부분에서든 알아낼 수 있어야 하죠. 이렇게 하려면 전역변수를 써야 해요. 전역 변수를 쓰려면, 변수 이름 앞에 $자를 붙여주기만 하면 되요. 예를 들면 $global, $nestingDepth, $bigTopPeeWee 이런 식으로 쓰면 되죠. 결국 일지기록 프로그램을 실행시킨 결과는 다음과 같이 나오게 됩니다.

Beginning "outer block"...
  Beginning "some little block"...
    Beginning "teeny-tiny block"...
    ..."teeny-tiny block" finished, returning:  lots of love
  ..."some little block" finished, returning:  42
  Beginning "yet another block"...
  ..."yet another block" finished, returning:  I love Indian food!
..."outer block" finished, returning:  true

10장을 마무리하며

자, 이제 10장에서 배울 내용은 다 살펴 보았습니다. 많이 배우셨어요! 다 기억이 안난다고 생각하실지도 모르지만, 그리고 어떤 부분은 건너뛰었다고 생각하실 지도 모르지만, 진짜로, 진짜로 괜찮아요. 프로그래밍을 하려면 모든 것을 알아야 하는 게 아니에요. 알아낼 수 있으면 되요. 잊어버린 것을 어디서 찾을지를 안다면, 그 정도면 괜찮은 거에요. 이 튜토리얼을 쓸 때, 여기저기 찾아보지 않고 이 튜토리얼의 코드를 다 작성했다고 생각하시면 오산이에요. 몇 분이 멀다하고 뒤적거렸거든요. 그리고 이 튜토리얼에서 예제로 사용되는 코드를 작성하면서 많은 도움을 받았어요. 어디에서 이런 정보들을 찾았느냐고요? 누구에게 도움을 청했느냐고요? 알려 드릴게요 ^^

참고

댓글

댓글 본문