이번 글에서는 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~~