본문 바로가기
카테고리 없음

Comparable 모듈 사용해서 객체 정렬하기1

by 경자꿈사 2024. 11. 15.

이번 글에서는 Comparable 모듈에 대해 살펴볼 건데, Comparable 모듈을 사용하여 클래스를 만들면 해당 클래스의 객체들 간의 크기 비교를 손쉽게 할 수 있다.
이전 'Enumerable 파헤치기' 글을 통해 Enumerable 모듈을 인클루드할 클래스에서 each 메서드 하나만 정의해 주면 each 메서드를 기반으로 정의된 Enumerable 모듈의 많은 메서드들을 손쉽게 사용할 수 있다는 것을 알았다.
Comparable 모듈도 Comparable 모듈을 인클루드할 클래스에서 <=> 모양의 '우주선 연산자(spaceship operator)'를 정의해 놓으면 Comparable 모듈이 제공하는 비교 관련 여러 메서드들을 사용할 수 있게 된다.

아래 그림을 보면 숫자 관련 클래스인 Integer, Float의 부모 클래스인 Numeric 클래스가 이미 Comparable 모듈을 인클루드하고 있고 <=> 연산자도 정의해 놓았음을 알 수 있다.
숫자 1과 2를 비교 연산자를 사용해서 비교해 보면 예상한 대로 결과를 잘 반환해 주는 걸 볼 수 있다.

<=> 연산자를 직접 정의할 때는 왼쪽(호출 대상 객체)이 오른쪽(인수로 건넨 객체)보다 크면 양수를 작으면 음수를 같으면 0을 돌려주면 되는데,  
그러면 Numeric 클래스의 객체를 직접 생성해서 Numeric 클래스에서 정의한 <=> 연산자의 반환값을 한번 확인해 보자.
아래 그림을 보면 동일한 객체에 대해서는 <=> 연산자가 0을 돌려주지만 그렇지 않은 경우에는 nil을 돌려주는 걸 볼 수 있다.
Numeric 클래스가 숫자 관련 클래스들을 위한 베이스 역할을 해주긴 하지만 실제적인 값을 표현하지는 않기 때문에 크고 작음을 판별할 수 없고, 그래서 비교 대상 객체가 다른 객체라면 nil을 반환하도록 정의해 놓은 것 같다.

그렇다면 앞서 숫자 1과 2를 비교 연산자로 테스트했을 때 Numeric 클래스에서 정의한 <=> 연산자를 사용하지 않았다는 얘기인데, 그러면 Integer나 Float 클래스에서 <=> 연산자를 재정의 해 놓았는지 확인해 보자.
아래 그림을 보면 실제 Integer와 Float 클래스에서 <=> 연산자를 재정의 해 놓은 것을 볼 수 있다.
그리고 <=> 연사자를 사용하여 숫자 2와 1을 비교하면 1이 반환되고 1과 1을 비교하면 0, 그리고 1과 2를 비교하면 -1이 반환되는 것도 볼 수 있다.

<=> 연산자를 직접 정의할 때, 특별한 이유가 아니라면 루비 기본 클래스에서 정의한 방식대로 '양수, 0, 음수'보다는 '1, 0, -1'을 반환해 주는 것이 좋다.
아래 Comparable 모듈을 흉내 내서 구현해 본 MyComparable 모듈과 MyComparable 모듈을 인클루드해 구현한 Person 클래스의 코드가 있다.
코드를 보면 MyComparable 모듈의 comp 메서드는 현재 객체(self)와 비교 대상 객체(other)를 <=> 연산자로 비교하여 그 결괏값을 반환한다.
그리고 MyComparable 모듈이 실제 구현해야 할 비교 연산자(메서드)들은 comp 메서드를 호출하여 받은 값을 다시 0과 비교하여 최종 결괏값을 반환한다.

module MyComparable
  def >(other)
    comp(other) > 0
  end
  
  def <(other)
    comp(other) < 0
  end  
  
  def >=(other)
    comp(other) >= 0  
  end
  
  def <=(other)
    comp(other) <= 0  
  end
  
  def ==(other)
    comp(other) == 0  
  end
  
  private 
  def comp(other)
    self <=> other
  end
end

 

Person 클래스는 MyComparable 모듈을 include로 인클루드하고, 현재 객체와 비교 대상 객체의 나이를 <=> 연산자로 비교한 결괏값을 그대로 돌려주는 것으로 <=> 연산자를 정의하였다.

require './my_comparable'

class Person
  include MyComparable
  attr_reader :age

  def initialize(age)
    @age = age
  end
  
  def <=>(other)
    age <=> other.age
  end
end

 

이제 Person 클래스가 MyComparable 모듈을 인클루드해서 얻게 된 비교 연산자들을 사용하여 Person 객체들을 서로 비교해 보자.
테스트를 위해 먼저 D:/blog/ruby 폴더 아래 comparable 폴더를 하나 만들고 그 폴더 안에 MyComparable 모듈의 코드는 my_comparable.rb 파일에, Person 클래스의 코드는 person.rb 파일에 저장하자.
그리고 comparable 폴더의 위치에서 irb를 실행한 후 아래 그림처럼 코드를 입력하면서 결과를 확인해 보자.

원하는 대로 Person 객체끼리 나이를 기준으로 크기 비교가 잘 되는 것을 볼 수 있다.

 

그런데, 만약 Person 클래스에서 <=> 연산자를 정의하지 않으면 어떻게 될까? 
Person 클래스가 <=> 연산자도 정의하지 않고 MyComparable 모듈도 인클루드하지 않는다면, Person 객체에 대해 MyComparable 모듈에서 정의한 비교 연산자를 사용할 수는 없겠지만 <=> 연산자는 사용할 수 있다.
이미 Kernel 모듈에서 <=> 연산자를 정의해 놓았기 때문이다. 아래 그림을 보면 <=> 연산자로 비교하는 두 Person 객체가 동일한 객체일 때는 0을 그렇지 않으면 nil을 반환하는 걸 볼 수 있다.

이번에는 아래 그림처럼 Person 클래스에 == 연산자를 추가로 정의한 후에 다시 테스트해 보자. 
실제는 == 연산자 역시 BasicObject 클래스에서 이미 정의를 해 놓았기 때문에 Person 클래스는 == 을 재정의하게 되는 것이다.
테스트 결과를 보면 p1과 p3가 실제 다른 Person 객체이지만 p1 <=> p3 의 결괏값이 0이 나왔다. 
이것은 Kernel 모듈에서 == 연산자를 사용해서 <=> 연산자를 정의해 놓았다는 얘기이다.
그런데 p1 <=> p1 에서는 Person 클래스의 == 연산자에서 출력하는 메시지가 출력되지 않는다. 아마도 == 연산자를 호출하기 전에 동일 객체인지를 먼저 확인하는 것 같다.
BasicObject 클래스는 자신을 상속해 만들어질 클래스의 객체들을 비교할 방법을 알 수가 없기 때문에 동일 객체일 경우에만 0을 반환하고 나머지 경우에는 nil을 반환하도록 == 연산자를 정의해 놓았을 것이다.

아래 그림을 보면 Numeric 클래스를 상속하여 MyNumber 클래스를 만들고 Person 클래스처럼 == 연산자를 재정의해 놓았지만 여전히 <=> 연산자는 동일한 객체에 대해서만 0을 돌려준다.
같은 숫자가 두 개 이상일 필요는 없으니 Numeric 클래스에서는 <=> 연산자를 좀 더 엄격하게 재정의해 놓은 게 아닌가 싶다.

그러나 실제 Numeric의 하위 클래스인 Integer와 Float 클래스에서는 서로 다른 클래스의 두 객체에 대해서도 실제 크기가 같다면 <=> 연산자가 0을 돌려 주도록 재정의해 놓았다.
아래 그림을 보면 Integer 클래스의 객체인 숫자 1과 Float 클래스의 객체인 숫자 1.0을 <=> 연산자로 비교해 보면 결괏값이 1인 걸 알 수 있고, == 연산자도 true를 돌려주는 것도 볼 수 있다.

우리가 생각하는 상식에 맞게 재정의를 잘 해 놓은 것 같다.


한 가지 궁금한 게 더 있다. 만약 루비의 Comparable 모듈이 MyComparable 모듈처럼 <=> 연산자의 결괏값을 다시 0과 비교하도록 구현을 했고, 숫자를 비교할 때 Comparable 모듈에서 정의한 연산자(메서드)를 사용한다면 무한 루프에 빠져야 한다.
그런데 이미 위에서 숫자 1과 2를 가지고 <=> 연산자를 사용하여 테스트를 해봤었고, 결괏값(0, 1, -1 중 하나)을 잘 반환해 주었다.
이것은 결국, 숫자 자체가 서로 누가 크고 작은지를 판단할 수 있다는 얘기인데, 아래 그림을 보면 실제 Integer 클래스와 Float 클래스에서 직접 비교 연산자들을 재정의 했다는 것을 알 수 있다.
그래서 숫자 간의 비교 시에는 Comparable 모듈에서 정의한 비교 연산자를 사용하지 않고, 무한 루프에도 빠지지 않는다.

instance_methods 메서드를 사용하면 특정 클래스의 인스턴스(객체)에 대해 호출할 수 있는 메서드들이 뭐가 있는지 확인할 수 있는데, 인수로 false를 주면 해당 클래스에서 새롭게 추가했거나 재정의한 메서드들의 이름만 배열에 담아 돌려준다.
instance_methods는 private으로 지정된 인스턴스 메서드들은 제외하기 때문에 private 인스턴스 메서드는 private_instance_methods로 확인해야 한다.
문자열이나 심볼이 담긴 배열에서 grep 메서드를 사용하면 정규 표현식을 통해 내가 찾고 싶은 것을 쉽게 찾을 수 있는데, 위의 예제 코드에서도 Integer와 Float 클래스의 인스턴스 메서드들 중에 비교 연산자 메서드가 포함되어 있는지 확인하기 위해 grep 메서드를 사용하였다.
정규 표현식 /[<=>]/ 을 사용하면 대상 문자열(또는 심볼)안에 '<', '=', '>' 세 문자 중 어느 하나라도 포함되어 있으면 매칭이 되는 것이므로 결국, 메서드 이름이 담긴 배열에서 비교 연산자(>, <, >=, <=, == 등)를 찾게 된다.

이번에는 문자열 간의 비교를 한번 살펴보자.
아래 그림을 보면 String 클래스는 Comparable 모듈을 인클루드하고 있고 <=> 연산자 메서드도 정의하고 있는 걸 알 수 있다. 
String 클래스는 == 연산자를 제외한 비교 연산자(>, <, >=, <=)에 대해서는 Comparable 모듈에서 정의한 비교 연산자를 호출하게 될 것이다.

그러면 실제로 두 숫자를 비교할 때는 Comparable 모듈의 비교 연산자를 호출하지 않고, 두 문자열을 비교할 때는 Comparable 모듈의 비교 연산자를 호출하는지 직접 확인해 보자.
루비는 이미 정의된 클래스나 모듈을 변경할 수 있는 기능인 오픈 클래스(Open Class)를 지원하는데, 루비가 기본으로 제공하는 클래스나 모듈도 변경이 가능하다.
아래 코드를 보면 Comparable 모듈과 Integer 클래스의 정의를 다시 열어(Open) > 연산자를 재정의한 걸 볼 수 있다.

module Comparable
  alias_method :gt, :>
  
  def >(other)
    puts "Comparable's > is called"
    __send__(:gt, other)
  end
end

class Integer
  alias_method :gt, :>
  
  def >(other)
    puts "Integer's > is called"
    __send__(:gt, other)
  end
end

puts 2 > 1
puts "b" > "a"

 

앞의 코드를 D:/blog/ruby/comparable 폴더 아래 test_open_class.rb 파일에 저장한 후 해당 폴더의 위치에서 cmd를 열고 test_open_class.rb를 실행해 보자.

 

결과를 보면 예상한 대로 숫자를 비교하기 위해 사용한 > 연산자는 Integer 클래스에서 재정의한 > 연산자를 호출하고, 문자열 비교에선 Comparable 모듈의 > 연산자를 호출하는 것을 볼 수 있다.
프로그래밍 언어에서 제공하는 기본 클래스와 모듈까지도 변경할 수 있다는 점에서 오픈 클래스는 강력한 기능이지만 그만큼 신중히 사용해야 한다. 그리고 특히 가능한 한 기본 클래스와 모듈은 변경하지 않는 게 좋다.

다음 글에서는 여러 가지 도형에 대한 클래스들을 만들어 도형 객체들 간에 면적을 기준으로 크기를 비교해 보고, 또 여러 도형 객체들을 담은 배열을 정렬해 보겠다.


See you again~~