Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

루비 블록(Block), 프록(Proc), 람다(Lambda) 완벽 가이드

루비의 블록(Block), 프록(Proc), 람다(Lambda)는 많은 개발자들이 헷갈려 하는 핵심 개념입니다.

이것들은 정확히 무엇일까요? 어떻게 동작하고, 서로는 어떤 차이가 있을까요?

이 글 하나로 위 질문에 대한 답은 물론, 실무에서 바로 활용할 수 있는 내용까지 모두 정리해 드립니다!

목차

  • 1 루비 블록 이해하기
  • 2 루비 yield 키워드
  • 3 암시적 블록 vs 명시적 블록
  • 4 블록 전달 여부 확인 방법
  • 5 람다란 무엇인가?
  • 6 람다 vs 프록
  • 7 클로저(Closure)
  • 8 Binding 클래스
  • 9 마치며

루비 블록 이해하기

루비 블록은 메서드에 전달할 수 있는 작은 익명 함수입니다.

블록은 do / end 문으로 감싸거나 중괄호 {} 사이에 작성하며, 여러 개의 인수를 가질 수도 있습니다.

인수 이름은 두 개의 파이프 문자 | 사이에 정의합니다.

지금까지 each를 사용해 보셨다면, 이미 블록을 사용하고 계셨던 겁니다!

예시를 살펴보겠습니다:

# 형식 1: 한 줄짜리 블록에 권장
[1, 2, 3].each { |num| puts num }
                 ^^^^^ ^^^^^^^^
                 블록     블록
               인수      본문
# 형식 2: 여러 줄짜리 블록에 권장
[1, 2, 3].each do |num|
  puts num
end

루비 블록이 유용한 이유는 특정 로직(코드)을 저장해 두었다가 나중에 재사용할 수 있기 때문입니다.

예를 들어 파일에 데이터를 쓰거나, 요소 간 동등 여부를 비교하거나, 에러 메시지를 출력하는 등의 작업에 활용할 수 있습니다.

루비 yield 키워드

그렇다면 루비에서 yield는 무엇을 의미할까요?

yield는 사용 시점에 블록을 호출하는 루비 키워드입니다.

바로 이 키워드를 통해 메서드가 블록을 '사용'하는 것입니다!

yield 키워드를 실행하면 블록 안의 코드가 실제로 동작하며 자신의 역할을 수행합니다.

일반적인 루비 메서드를 호출하는 것과 마찬가지입니다.

예시입니다:

def print_once
  yield
end

print_once { puts "Block is being run" }

이 코드는 print_once에 전달된 블록을 실행하므로, 화면에 "Block is being run"이 출력됩니다.

알고 계셨나요?

yield는 여러 번 사용할 수도 있습니다.

yield를 호출할 때마다 블록이 실행되기 때문에, 같은 메서드를 반복해서 호출하는 것과 같습니다.

예시:

def print_twice
  yield
  yield
end

print_twice { puts "Hello" }

# "Hello"
# "Hello"

메서드와 마찬가지로...

yield에는 원하는 만큼 인수를 전달할 수 있습니다.

예시:

def one_two_three
  yield 1
  yield 2
  yield 3
end

one_two_three { |number| puts number * 10 }
# 10, 20, 30

이렇게 전달된 인수들은 그대로 블록의 인수가 됩니다.

위 예시에서는 number가 해당됩니다.

암시적 블록 vs 명시적 블록

블록에는 '명시적(explicit)'과 '암시적(implicit)' 두 종류가 있습니다.

명시적 블록이란 매개변수 목록에서 이름을 지정해 준 블록을 말합니다.

명시적 블록은 다른 메서드에 전달하거나 변수에 저장해 두었다가 나중에 사용할 수 있습니다.

예시를 보겠습니다:

def explicit_block(&block)
  block.call # yield와 동일
end

explicit_block { puts "Explicit block called" }

&block 매개변수에 주목하세요...

바로 이 방식으로 블록에 이름을 지정하는 것입니다!

블록 전달 여부 확인 방법

블록 없이 yield를 호출하면 no block given (yield) 오류가 발생합니다.

이때 block_given? 메서드를 사용하면 블록이 전달되었는지 미리 확인할 수 있습니다.

예시:

def do_something_with_block
  return "No block given" unless block_given?
  yield
end

이처럼 작성하면 누군가 블록 없이 메서드를 호출하더라도 오류를 예방할 수 있습니다.

람다(Lambda)란 무엇인가?

람다는 블록과 그 매개변수를 특별한 문법으로 정의하는 방법입니다.

정의한 람다는 변수에 저장해 두었다가 필요할 때 사용할 수 있습니다.

루비 람다를 정의하는 기본 문법은 다음과 같습니다:

say_something = -> { puts "This is a lambda" }

-> 대신 lambda 키워드를 사용하는 대체 문법도 있습니다.

람다를 정의한다고 해서 내부 코드가 바로 실행되지는 않습니다. 메서드를 정의만 한다고 실행되지 않는 것과 같으며, 실행하려면 call 메서드를 사용해야 합니다.

예시:

say_something = -> { puts "This is a lambda" }
say_something.call

# "This is a lambda"

람다를 호출하는 방법은 call 외에도 몇 가지가 있습니다. 존재 자체는 알아두면 좋지만, 가독성을 위해 call 사용을 권장합니다.

전체 목록은 다음과 같습니다:

my_lambda = -> { puts "Lambda called" }

my_lambda.call
my_lambda.()
my_lambda[]
my_lambda.===

람다는 인수도 받을 수 있습니다. 예시를 살펴보겠습니다:

times_two = ->(x) { x * 2 }
times_two.call(10)
# 20

람다에 잘못된 개수의 인수를 전달하면 일반 메서드와 마찬가지로 예외가 발생합니다.

람다 vs 프록

프록(Proc)은 람다와 아주 유사한 개념입니다...

차이점 중 하나는 생성 방식입니다.

예시:

my_proc = Proc.new { |x| puts x }

루비에는 별도의 Lambda 클래스가 존재하지 않습니다. 람다는 특별한 Proc 객체일 뿐입니다. Proc의 인스턴스 메서드를 살펴보면 lambda?라는 메서드가 있는 것을 확인할 수 있습니다.

이제 중요한 부분입니다:

프록은 람다와 다르게 동작하며, 특히 인수 처리에서 그 차이가 두드러집니다:

t = Proc.new { |x,y| puts "I don't care about arguments!" }
t.call
# "I don't care about arguments!"

proclambda의 또 다른 차이점은 return 문에 대한 반응 방식입니다.

lambda는 일반 메서드처럼 정상적으로 return합니다.

하지만 proc은 현재 컨텍스트(context)에서 벗어나려고 시도합니다.

무슨 의미인지 살펴보겠습니다:

아래 코드를 실행하면 procLocalJumpError 예외를 발생시키는 것을 확인할 수 있습니다.

그 이유는 최상위(top-level) 컨텍스트에서는 return할 수 없기 때문입니다.

직접 확인해 보세요:

# 정상 동작
my_lambda = -> { return 1 }
puts "Lambda result: #{my_lambda.call}"

# 예외 발생
my_proc = Proc.new { return 1 }
puts "Proc result: #{my_proc.call}"

만약 proc이 메서드 내부에 있다면, return 호출은 해당 메서드에서 반환하는 것과 동일하게 동작합니다.

다음 예시에서 이를 확인할 수 있습니다.

def call_proc
  puts "Before proc"
  my_proc = Proc.new { return 2 }
  my_proc.call
  puts "After proc"
end

p call_proc
# "Before proc"만 출력되고 "After proc"은 출력되지 않음

proclambda의 차이점을 정리하면 다음과 같습니다:

  • 람다는 -> {}로, 프록은 Proc.new {}로 정의합니다.
  • 프록은 현재 메서드에서 반환(return)하지만, 람다는 람다 자체에서 반환합니다.
  • 프록은 인수 개수가 맞지 않아도 신경 쓰지 않지만, 람다는 예외를 발생시킵니다.

이 목록을 통해 알 수 있는 사실은, lambdaproc보다 일반 메서드에 훨씬 가깝다는 점입니다.

클로저(Closure)

루비의 프록과 람다는 또 하나의 특별한 속성을 가집니다. 루비 프록을 생성하면, 생성 당시의 실행 스코프(scope)가 함께 저장됩니다.

'클로저(closure)'라고 불리는 이 개념은, proc이 정의된 위치의 지역 변수나 메서드 같은 값들을 함께 품고 다닌다는 의미입니다.

단, 실제 값 자체가 아니라 참조(reference)를 저장하기 때문에, 프록 생성 이후 변수가 변경되면 프록은 항상 최신 값을 사용합니다.

예시를 살펴보겠습니다:

def call_proc(my_proc)
  count = 500
  my_proc.call
end

count   = 1
my_proc = Proc.new { puts count }

p call_proc(my_proc) # 무엇이 출력될까요?

이 예시에는 1로 설정된 지역 변수 count가 있습니다.

또한 my_proc이라는 프록과, 인수로 전달된 모든 프록 또는 람다를(call 메서드를 통해) 실행하는 call_proc 메서드가 있습니다.

이 프로그램은 무엇을 출력할까요?

500이 가장 합리적인 답처럼 보이지만, '클로저' 효과 때문에 실제 출력 결과는 1입니다.

프록이 정의된 위치, 즉 메서드 정의 외부의 count 값을 사용하기 때문입니다.

Binding 클래스

그렇다면 루비의 프록과 람다는 이 스코프 정보를 어디에 저장할까요?

Binding 클래스에 대해 소개해 드리겠습니다...

binding 메서드로 Binding 객체를 생성하면, 코드의 해당 지점을 가리키는 '앵커(anchor)'를 만드는 셈입니다.

그 지점에서 정의된 모든 변수, 메서드, 클래스는 완전히 다른 스코프에 있더라도 이 객체를 통해 나중에 접근할 수 있습니다.

예시:

def return_binding
  foo = 100
  binding
end

# foo는 binding 덕분에 접근 가능합니다.
# foo가 정의된 메서드 밖에 있음에도 말이죠.
puts return_binding.class
puts return_binding.eval('foo')

# foo를 직접 출력하려 하면 오류가 발생합니다.
# foo는 메서드 밖에서 정의된 적이 없기 때문입니다.
puts foo

즉, binding 객체의 컨텍스트에서 코드를 실행하는 것은, 그 코드가 binding이 정의된 바로 그 위치에 있는 것과 동일합니다('앵커' 비유를 기억하세요).

binding 객체를 직접 사용할 일은 많지 않지만, 이런 개념이 존재한다는 것은 알아두면 좋습니다 🙂

마치며

이 글에서는 블록이 어떻게 동작하는지, 루비의 프록과 람다의 차이점은 무엇인지, 그리고 블록을 생성할 때마다 발생하는 '클로저' 효과까지 함께 살펴보았습니다.

다룰 수 있었지만 생략한 주제가 하나 있는데, 바로 curry 메서드입니다.

이 메서드를 사용하면 필요한 인수 중 일부 또는 전부를 미리 넘겨둘 수 있습니다. 인수를 일부만 전달하면 해당 인수들이 '미리 장착된(pre-loaded)' 새로운 프록이 반환되고, 모든 인수가 채워지는 순간 프록이 실행됩니다.

이 글이 도움이 되셨기를 바랍니다!

아래 폼을 통해 구독하는 것도 잊지 마시고, 친구들에게 공유해 주세요 🙂