블록과 프로시저

블록과 프로시저 (Blocks and Procs)

메서드는 yield 키워드로 실행되는 코드 블록을 받을 수 있어요. 가장 단순한 예로 시작해 볼게요.

def twice(&)
  yield
  yield
end

twice do
  puts "Hello!"
end

위 프로그램은 "Hello!"를 두 번 출력해요. yield가 두 번 있으니까요.

블록을 받는 메서드를 정의하는 방법은 간단해요. 본문 안에서 yield를 쓰기만 하면 컴파일러가 알아서 알아요. 더 명확하게 나타내고 싶다면 앰퍼샌드(&)로 시작하는 더미 블록 매개변수를 마지막에 선언할 수 있어요. 위 예시에서는 매개변수를 익명으로(&만 적어서) 작성했는데, 이름을 붙일 수도 있어요.

def twice(&block)
  yield
  yield
end

이 예시에서 블록 매개변수의 이름은 중요하지 않지만, 더 고급 사용법에서는 의미가 있어져요.

출처: Crystal 공식 문서

본문

메서드를 호출하면서 블록을 넘길 때는 do ... end{ ... }를 사용해요. 아래는 모두 같은 표현이에요.

twice() do
  puts "Hello!"
end

twice do
  puts "Hello!"
end

twice { puts "Hello!" }

do ... end{ ... }의 차이는 바인딩 방식이에요. do ... end는 가장 왼쪽 호출에 묶이고, { ... }는 가장 오른쪽 호출에 묶여요.

foo bar do
  something
end

# The above is the same as
foo(bar) do
  something
end

foo bar { something }

# The above is the same as

foo(bar { something })

이렇게 동작하는 이유는 do ... end를 사용해 도메인 특화 언어(DSL)를 만들 때 마치 평범한 영어 문장처럼 읽히게 하려는 거예요.

open file "foo.cr" do
  something
end

# Same as:
open(file("foo.cr")) do
  something
end

위 코드가 아래처럼 해석되면 원하지 않을 거예요.

open(file("foo.cr") do
  something
end)

오버로드

yield하는 메서드와 그렇지 않은 메서드는 서로 다른 오버로드로 간주돼요. 자세한 내용은 overloading 문서에서 다뤄요.

yield 인자

yield 표현식은 호출과 비슷해서 인자를 받을 수 있어요.

def twice(&)
  yield 1
  yield 2
end

twice do |i|
  puts "Got #{i}"
end

위 코드는 "Got 1"과 "Got 2"를 출력해요.

중괄호 표기법도 사용할 수 있어요.

twice { |i| puts "Got #{i}" }

여러 값을 yield할 수도 있어요.

def many(&)
  yield 1, 2, 3
end

many do |x, y, z|
  puts x + y + z
end

# Output: 6

블록은 yield된 인자의 개수보다 더 적은 매개변수를 지정할 수 있어요.

def many(&)
  yield 1, 2, 3
end

many do |x, y|
  puts x + y
end

# Output: 3

반대로 yield된 인자의 개수보다 블록 매개변수를 더 많이 지정하면 오류가 나요.

def twice(&)
  yield
  yield
end

twice do |i| # Error: too many block parameters
end

각 블록 매개변수의 타입은 해당 위치의 모든 yield 표현식의 타입과 같아져요.

def some(&)
  yield 1, 'a'
  yield true, "hello"
  yield 2, nil
end

some do |first, second|
  # first is Int32 | Bool
  # second is Char | String | Nil
end

언더스코어도 블록 매개변수로 사용할 수 있어요.

def pairs(&)
  yield 1, 2
  yield 2, 4
  yield 3, 6
end

pairs do |_, second|
  print second
end

# Output: 246

짧은 단일 매개변수 문법

블록의 매개변수가 하나뿐인데 그 매개변수에 메서드를 호출하는 형태라면, 블록을 짧은 문법 인자로 대체할 수 있어요.

이 코드는

method do |param|
  param.some_method
end

이 코드도

method { |param| param.some_method }

둘 다 이렇게 쓸 수 있어요.

method &.some_method

또는 이렇게요.

method(&.some_method)

어느 경우든 &.some_methodmethod에 전달되는 하나의 인자예요. 이 인자는 문법적으로 블록 형태와 동일해요. 순수한 문법 설탕(syntactic sugar)일 뿐이라 성능 비용은 없어요.

메서드에 필수 인자가 더 있다면, 짧은 문법 인자도 그 메서드의 인자 목록 안에 함께 넣어 줘야 해요.

["a", "b"].join(",", &.upcase)

위 코드는 아래와 같아요.

["a", "b"].join(",") { |s| s.upcase }

짧은 문법 인자에도 인자를 넘길 수 있어요.

["i", "o"].join(",", &.upcase(Unicode::CaseOptions::Turkic))

연산자도 호출할 수 있어요.

method &.+(2)
method(&.[index])

yield의 값

yield 표현식 자체도 값을 가져요. 바로 블록의 마지막 표현식의 값이죠.

def twice(&)
  v1 = yield 1
  puts v1

  v2 = yield 2
  puts v2
end

twice do |i|
  i + 1
end

위 코드는 "2"와 "3"을 출력해요.

yield 표현식의 값은 주로 값을 변환하거나 필터링할 때 유용해요. 대표적인 예로 Enumerable#mapEnumerable#select가 있어요.

ary = [1, 2, 3]
ary.map { |x| x + 1 }         # => [2, 3, 4]
ary.select { |x| x % 2 == 1 } # => [1, 3]

간단한 변환 메서드를 하나 만들어 볼게요.

def transform(value, &)
  yield value
end

transform(1) { |x| x + 1 } # => 2

결과가 2인 이유는 transform 메서드의 마지막 표현식이 yield이고, 그 값이 블록의 마지막 표현식의 값이기 때문이에요.

타입 제한

yield를 사용하는 메서드에서 블록의 타입은 &block 문법으로 제한할 수 있어요.

def transform_int(start : Int32, &block : Int32 -> Int32)
  result = yield start
  result * 2
end

transform_int(3) { |x| x + 2 } # => 10
transform_int(3) { |x| "foo" } # Error: expected block to return Int32, not String

break

블록 안의 break 표현식은 메서드에서 조기 종료를 시켜요.

def thrice(&)
  puts "Before 1"
  yield 1
  puts "Before 2"
  yield 2
  puts "Before 3"
  yield 3
  puts "After 3"
end

thrice do |i|
  if i == 2
    break
  end
end

위 코드는 "Before 1"과 "Before 2"를 출력해요. break 때문에 thrice 메서드가 puts "Before 3" 표현식을 실행하지 않았어요.

break는 인자도 받을 수 있는데, 그 값이 메서드의 반환값이 돼요.

def twice(&)
  yield 1
  yield 2
end

twice { |i| i + 1 }         # => 3
twice { |i| break "hello" } # => "hello"

첫 번째 호출의 값은 3이에요. twice 메서드의 마지막 표현식이 yield이고, 그것이 블록의 값을 받기 때문이죠. 두 번째 호출의 값은 "hello"인데, break가 수행됐기 때문이에요.

조건부로 break하는 경우에는 호출의 반환값 타입이 블록 값의 타입과 여러 break의 타입의 유니온이 돼요.

value = twice do |i|
  if i == 1
    break "hello"
  end
  i + 1
end
value # :: Int32 | String

break가 여러 인자를 받으면 자동으로 Tuple로 변환돼요.

values = twice { break 1, 2 }
values # => {1, 2}

break가 인자를 받지 않으면 nil 하나를 받은 것과 같아요.

value = twice { break }
value # => nil

break가 중첩된 여러 블록 안에서 사용되면, 단지 바로 감싼 블록만 벗어나요.

def foo(&)
  pp "before yield"
  yield
  pp "after yield"
end

foo do
  pp "start foo1"
  foo do
    pp "start foo2"
    break
    pp "end foo2"
  end
  pp "end foo1"
end

# Output:
# "before yield"
# "start foo1"
# "before yield"
# "start foo2"
# "end foo1"
# "after yield"

"after yield"가 두 번 나오지도 않고 "end foo2"도 출력되지 않는 것에 주목하세요.

next

블록 안의 next 표현식은 메서드가 아니라 블록에서 조기 종료를 시켜요.

def twice(&)
  yield 1
  yield 2
end

twice do |i|
  if i == 1
    puts "Skipping 1"
    next
  end

  puts "Got #{i}"
end

# Output:
# Skipping 1
# Got 2

next 표현식은 인자를 받을 수 있고, 그 값이 블록을 호출한 yield 표현식의 값이 돼요.

def twice(&)
  v1 = yield 1
  puts v1

  v2 = yield 2
  puts v2
end

twice do |i|
  if i == 1
    next 10
  end

  i + 1
end

# Output
# 10
# 3

next가 여러 인자를 받으면 자동으로 Tuple로 변환돼요. 인자를 받지 않으면 nil 하나를 받은 것과 같아요.

with ... yield

yield 표현식은 with 키워드로 수정해, 블록 안의 메서드 호출의 기본 수신자로 사용할 객체를 지정할 수 있어요.

class Foo
  def one
    1
  end

  def yield_with_self(&)
    with self yield
  end

  def yield_normally(&)
    yield
  end
end

def one
  "one"
end

Foo.new.yield_with_self { one } # => 1
Foo.new.yield_normally { one }  # => "one"

블록 매개변수 언패킹

블록 매개변수는 괄호로 감싼 하위 매개변수를 지정할 수 있어요.

array = [{1, "one"}, {2, "two"}]
array.each do |(number, word)|
  puts "#{number}: #{word}"
end

위 코드는 이 코드의 문법 설탕이에요.

array = [{1, "one"}, {2, "two"}]
array.each do |arg|
  number = arg[0]
  word = arg[1]
  puts "#{number}: #{word}"
end

즉 정수로 []에 응답하는 모든 타입을 블록 매개변수에서 언패킹할 수 있어요.

매개변수 언패킹은 중첩될 수 있어요.

ary = [
  {1, {2, {3, 4}}},
]

ary.each do |(w, (x, (y, z)))|
  w # => 1
  x # => 2
  y # => 3
  z # => 4
end

스플랫 매개변수도 지원돼요.

ary = [
  [1, 2, 3, 4, 5],
]

ary.each do |(x, *y, z)|
  x # => 1
  y # => [2, 3, 4]
  z # => 5
end

Tuple 매개변수라면 자동 스플랫(auto-splatting)을 활용할 수 있어서 괄호가 필요 없어요.

array = [{1, "one", true}, {2, "two", false}]
array.each do |number, word, bool|
  puts "#{number}: #{word} #{bool}"
end

Hash(K, V)#each는 블록에 Tuple(K, V)를 넘겨 주기 때문에, 키-값 쌍을 반복할 때 자동 스플랫으로 처리할 수 있어요.

h = {"foo" => "bar"}
h.each do |key, value|
  key   # => "foo"
  value # => "bar"
end

성능

yield로 블록을 사용할 때, 블록은 항상 인라인돼요. 클로저나 호출, 함수 포인터가 관여하지 않죠. 즉 이 코드는

def twice(&)
  yield 1
  yield 2
end

twice do |i|
  puts "Got: #{i}"
end

이 코드와 정확히 같아요.

i = 1
puts "Got: #{i}"
i = 2
puts "Got: #{i}"

표준 라이브러리에는 정수에 times 메서드가 있어서 이렇게 쓸 수 있어요.

3.times do |i|
  puts i
end

겉보기엔 화려해 보이는데, C의 for 루프만큼 빠를까요? 답은 그렇다예요.

이것이 Int#times의 정의예요.

struct Int
  def times(&)
    i = 0
    while i < self
      yield i
      i += 1
    end
  end
end

캡처되지 않은 블록은 항상 인라인되므로, 위 메서드 호출은 이 코드와 정확히 같아요.

i = 0
while i < 3
  puts i
  i += 1
end

가독성을 위해, 혹은 코드 재사용을 위해 블록을 쓰는 것을 두려워하지 마세요. 결과 실행 파일의 성능에는 영향을 주지 않아요.

더 알아보기

  • overloading: 블록을 받는 메서드와 그렇지 않은 메서드의 오버로드 차이
  • assignment의 underscore: 블록 매개변수로 쓰는 언더스코어
  • Enumerable#map, Enumerable#select: yield의 값을 활용한 대표 사례