[번역] 사용해봐야 할 5가지 루비 메소드

루비에는 당신의 손 끝에서 만들어 낼 수 있는 뭔가 마법같은 것이 있습니다. _why가 언젠가 말한 것 처럼 루비는 생각을 컴퓨터에게 표현하는 법을 알려줄 겁니다. 아마도 이게 루비가 현대 웹 프로그래밍에서 이렇게 대중적인 선택이 된 이유가 아닐까요?

영어에서 처럼, 루비에서는 같은 일을 할 때에도 많은 방법이 있습니다. 저는 Exercism에서 사람들의 코드를 읽고 지적(nitpicking)하는데 많은 시간을 사용하고, 종종 어떤 루비 메소드를 알았더라면 훨씬 더 간단하게 끝낼 수 있는 연습문제를 종종 봅니다.

여기에서는 특정 문제를 아주 잘 해결할 수 있는 좀 덜 알려진 루비 메소드에 대해 살펴보겠습니다.

Object#tap

어떤 객체에 메소드를 호출했는데, 리턴 값이 원하던게 아닌 적이 있나요? 그 객체를 리턴하길 원했지만, 뭔가 다른 값을 받았을 겁니다. 해쉬에 저장된 파라메터에 임의의 값을 추가하길 원했다고 합시다. 이 경우 Hash.[]으로 갱신했지만, params 해쉬 'bar’ 를 돌려받아 명시적으로 리턴을 해줘야 했습니다.

def update_params(params)
  params[:foo] = 'bar'
  params
end

이 메소드 끝의 params 줄은 이질적으로 보입니다.

Object#tap으로 깔끔하게 정리 할 수 있습니다.

사용하기도 쉽습니다. 객체에 tap을 콜하고 실행하기 원하는 코드 블록을 넘기면 됩니다. 객체는 블록 안에서 yield 되고 리턴 됩니다. update_params는 이렇게 고칠 수 있습니다.

def update_params(params)
  params.tap {|p| p[:foo] = 'bar' }
end

Object#tap를 사용하면 좋을 장소는 여러군데 있습니다. 나는 그냥 객체를 리턴하고 싶지만, 그러지 않는 메소드 호출을 찾아보세요.

Array#bsearch

다른 사람은 어떨지 모르지만 저는 배열에서 데이터를 많이 찾습니다. 루비의 enumerable은 원하는 걸 찾기 쉽게합니다. select, reject, find는 매일 사용하는 가치있는 툴입니다. 하지만 데이터가 크면, 모든 데이터를 통과하는데 걸리는 시간이 걱정되기 시작합니다.

SQL 데이터 베이스를 관리하는데 ActiveRecord를 사용한다면, 검색이 최소 복잡도로 실행되는지 확인하기 위해 무대뒤에서 일어나는 많은 마법들이 있습니다. 하지만 가끔은 작업하기 전에 데이터베이스에서 모든 데이터를 가져와야 할 때가 있습니다. 예를들어, 데이터가 DB안에서 암호화 되어 있다면, SQL로 아주 잘 쿼리할 방법은 없죠.

그럴 경우, 내가 생각할 수 있는 최소한의 빅 O 복잡도로 데이터를 선별하는 방법을 고민합니다. 빅 O 표기법을 잘 모르신다면, Justin Abrahms’s Big-O Notation Explained By A Self-Taught ProgrammerBig-O Complexity Cheat Sheet를 읽어보세요.

기본적인 요점은 알고리즘은 복잡도에 따라 시간이 더걸릴수도 있고 덜 걸릴수도 있다는 것입니다. 빠른 순서는 다음과 같습니다. O(1), O(log n), O(n), O(n log(n)), O(n2), O(2n), O(n!). 그래서 가능하면 검색이 리스트의 앞쪽의 복잡도가 되는걸 선호합니다.

루비에서 배열에서의 검색을 하려할 때, 재일 먼저 생각나는 것은 Enumerable#find입니다. detect로도 알려져 있죠. 하지만, 이 메소드는 찾을 때까지 전 리스트를 검색합니다. 레코드가 시작위치에 가까우면 별관계 없지만, 매우 긴 리스트의 끝부분에 있다면 문제가 됩니다. 검색에서 찾는데 O(n) 의 복잡도가 걸리기 때문이죠.

좀 더 빠른 방법이 있습니다. Array#bsearch를 사용하면, O(log n)의 복잡도로 찾을 수 있습니다. 바이너리 서치가 어떻게 동작하는지 더 자세히 알고 싶으시면 제 글 Building A Binary Search를 읽어보세요.

50,000,000개의 배열에서 검색할 때의 검색 속도의 차이입니다.

require 'benchmark'
data = (0..50_000_000)
Benchmark.bm do |x|
  x.report(:find) { data.find {|number| number > 40_000_000 } }
  x.report(:bsearch) { data.bsearch {|number| number > 40_000_000 } }
end
         user       system     total       real
find     3.020000   0.010000   3.030000   (3.028417)
bsearch  0.000000   0.000000   0.000000   (0.000006)

보시는 것 처럼, bsearch가 훨씬 빠릅니다. 하지만, bsearch를 사용하려면 배열이 정렬되어 있어야 한다는 꽤나 큰 조건이 붙습니다. 이 조건이 유용성이 좀 덜해지긴 하지만, created_at 타임스탬프로 데이터베이스에서 로드된 데이터를 찾을 때 처럼 종종 유용하게 사용할 때를 대비해 기억해 둘만한 가치는 있습니다.

Enumerable#flat_map

관계형 데이터를 다룰 때, 가끔 우리는 직접적으로 관계되지 않은 인수들을 모아서 중첩되지 않은 배열로 리턴해야 될 때가 있습니다. 블로그 어플이 있고, 주어진 유저 목록중에 저번달에 커맨트를 적은 사람을 찾으려 한다고 해봅시다.

아마도 이런 코드가 나올 것입니다.

module CommentFinder
  def self.find_for_users(user_ids)
    users = User.where(id: user_ids)
    user.posts.map do |post|
      post.comments.map |comment|
        comment.author.username
      end
    end
  end
end

결과는 이렇게 나오겠죠.

[[['Ben', 'Sam', 'David'], ['Keith']], [[], [nil]], [['Chris'], []]]

하지만 우리는 이름만 원하니 flatten을 사용할 수 있습니다.

module CommentFinder
  def self.find_for_users(user_ids)
    users = User.where(id: user_ids)
    user.posts.map { |post|
      post.comments.map { |comment|
        comment.author.username
      }.flatten
    }.flatten
  end
end

여기에서 flat_map을 사용할 수 도 있습니다.

이렇게 하면 가는대로 flatten 시킬수 있습니다.

module CommentFinder
  def self.find_for_users(user_ids)
    users = User.where(id: user_ids)
    user.posts.flat_map { |post|
      post.comments.flat_map { |comment|
        comment.author.username
      }
    }
  end
end

그렇게 많이 다르진 않지만, flatten을 여러번 부르는 것보단 나아요.

Array.new with a Block

부트캠프에 있을 때, 선생님이었던 Jeff Casimir (Turing School 창업자)가 Battleship 개임을 한시간 안에 만들도록 시킨적이 있습니다. 이 것은 객체지향 프로그래밍을 공부하는데에 훌륭한 연습이 되었습니다. 여기에는 Rules, Players, Games, Boards 가 필요 했습니다.

Board를 구현해보는 것은 재미있는 연습이었습니다. 몇번의 반복 끝에, 내가 발견한 8x8 그리드를 만드는 제일쉬운 방법은 이것이었습니다.

class Board
  def board
    @board ||= Array.new(8) { Array.new(8) { '0' } }
  end
end

어떻게 하는 걸까요? Array.new를 인자와 함께 호출하면, 그 길이 만큼의 배열을 만듭니다.

Array.new(8)
#=> [nil, nil, nil, nil, nil, nil, nil, nil]

블록을 넘기면, 블록을 수행한 결과를 배열의 맴버로 만듭니다.

Array.new(8) { 'O' }
#=> ['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O']

그래서 배열에 8개의 ‘O’ 요소를 만드는 배열을 만드는 블록을 가지는 8개의 요소를 넘기면, ‘O’ 문자열을 가지는 8x8 배열을 만들 수 있습니다.
Array#new를 블록과 함께 쓰는 패턴을 사용하면, 디폴트 데이터를 가지든 몇번 중첩되었든 상관없이 모든 종류의 기묘한 배열을 만들 수 있습니다.

<=>

우주선(정렬 연산자)는 제가 좋아하는 루비 문법입니다. 이 연산자는 대부분의 루비 클래스에 나타나고, enumerable과 함께 사용할 때 유용합니다.
어떻게 동작하는지 설명하기위해, Fixnum에서 어떻게 동작하는지 살펴 봅시다. 5<=>5을 하면 0이 리턴됩니다. 4<=>5을 하면 -1이 리턴됩니다. 5<=>4을 하면 1이 리턴됩니다. 기본적으로 두 숫자가 같으면, 0이 리턴됩니다. 작은 수에서 큰 수로 정렬될 때는 -1 그 밴대의 경우 1이 리턴됩니다.

우주선을 직접 만든 클래스에서 사용할수도 있습니다. comparable을 모듈을 인클루드 한 다음, 입맛에 맞게 -1, 0, 1을 리턴하도록 <=>를 재정의 하면 됩니다.

왜 이런게 필요할까요?

어느날 Exercism에서 발견한 멋진 사용법이 있습니다. Clock이라는 연습문제에서, 커스텀 +, - 메소드를 사용해 분을 조정할 필요가 있었습니다. 분단위가 비정상적이 되기 때문에 60분 이상을 더하려면 복잡해집니다. 그럴경우 시간이 증가하고 분에서 60을 빼도록 조정해야할 필요가 있습니다.

dalexj라는 한 유저가 우주선 연산자를 사용해 이 문제를 매우 멋지게 해결했습니다.

  def fix_minutes
    until (0...60).member? minutes
      @hours -= 60 <=> minutes
      @minutes += 60 * (60 <=> minutes)
    end
    @hours %= 24
    self
  end

이 코드는 이렇게 동작합니다. 분이 0에서 60사이가 될때 까지, 시간에 분이 60보다 크냐 아니냐에 따라 1이나 -1을 빼고 정렬에 따라 -60이나 60을 더하도록 분을 조정했습니다.
우주선은 객체에 커스텀 정렬 순서를 만들때 아주 좋고, 3가지 산술 연산 중에 하나를 사용한다는 것을 알고 있다면 산술연산에도 유용하게 쓸 수 있습니다.

정리

코드를 잘 쓰는것은 배움의 과정입니다. 루비는 언어이기 때문에, 능력을 향상시키기 위해 “문학” (예를 들면 Exercism이나 GitHub의 코드)을 읽고 내 언어의 사전(Rubydocs)을 읽는데 많은 시간을 사용합니다.

메소드를 더 많이 알면, 코드에 표현을 적기 더 쉬워집니다. 이 예제 모음이 당신의 루비 어휘를 풍부하게 했으면 합니다.

추신. 당신이 좋아하는 코드를 간결하고 이해하기 쉽게 만드는 루비 메소드는 무엇인가요? 댓글을 남겨주세요![1]

[1]: 아! 물론 원문가서 남기세요.

998 views