지난 글에서는 다룬 내용 중 몇 가지 중요한 핵심 사항들을 다시 떠올려 보자.
Comparable 모듈을 사용하기 위해서는 Comparable 모듈을 인클루드하는 클래스에서 <=> 연산자의 정의가 필요하다.
Kernel 모듈에서는 두 객체가 동일한 객체(object_id 값이 같다)이거나 == 연산자를 호출한 결괏값이 true 면 0을 아니면 nil을 반환하도록 <=> 연산자를 정의해 놓았다.
BasicObject 클래스에서는 두 객체가 동일한 객체일 경우 true를 아니면 false를 돌려주는 equal? 메서드를 정의해 놓았고 == 연산자도 equal? 메서드와 같은 동작을 하도록 해 놓았다.
우리가 만드는 클래스를 포함해서 BasicObject의 하위 클래스에서는 클래스의 특성에 맞게 == 연산자를 재정의하여 사용할 수 있다.
아래 그림을 보면 지금까지 우리가 주로 사용해왔던 기본 클래스들도 == 연산자를 재정의하고 있는 걸 알 수 있다.
그러나 equal? 메서드에 대한 재정의는 권장하지 않으며, 실제 루비에서 제공하는 많은 클래스들 중 equal? 메서드를 재정의하는 클래스는 단 하나도 없다.
ObjectSpace 모듈을 사용하면 현재 메모리에 존재하는 객체들을 살펴볼 수 있는데, each_object 메서드는 특정 클래스의 객체들을 순회할 수 있는 Enumerator 객체를 반환해 준다.
아래 그림은 현재 메모리에 존재하는 Class의 객체(모든 클래스는 Class의 객체이다.)들 중에 equal? 인스턴스 메서드를 정의(또는 재정의)한 클래스를 찾는 예제이다.
결과를 보면 equal? 메서드를 최초로 정의한 BasicObject 클래스만 나오는 것을 볼 수 있다.
그리고 Kernel 모듈에서 정의한 <=> 연산자는 Comparable 모듈을 인클루드해서 사용하기에는 부족하므로, 실제 Comparable 모듈을 인클루드하고 있는 클래스들에서는 <=> 연산자도 재정의하고 있다.
Numeric 클래스의 경우 Comparable 모듈을 인클루드하고 있고 <=> 연산자도 정의하고 있지만, Numeric 클래스의 객체가 어떠한 값을 표현하고 있지는 않기 때문에 <=> 연산자의 정의를 완벽히 할 수 없다.
따라서 Numeric 클래스의 하위 클래스인 Integer와 Float 클래스 등에서 <=> 연산자를 재정의하여 사용한다.
또한, Integer와 Float 클래스 같은 경우에는 <=> 연산자뿐만 아니라 >, <, >=, <= 와 같은 비교 연산자들마저도 재정의하고 있어 숫자 간의 비교 시 Comparable 모듈의 비교 연산자를 사용하지 않는다.
Integer, Float, String 같은 숫자와 문자를 나타내는 기본 클래스들에서 이미 <=> 연산자를 재정의해 놓았기 때문에 우리가 새롭게 만드는 클래스들에서 <=> 연산자를 정의하기는 어렵지 않다.
이제 본격적으로 Comparable 모듈을 사용하는 예제를 만들어 볼 건데, 지난 글에서 얘기한 대로 여러 가지 도형에 대한 클래스들을 만들어 도형 객체들 간에 면적을 기준으로 크기를 비교해 보자.
아래 코드를 보면 직사각형(Rectangle), 직각삼각형(RightTriangle), 원(Circle) 등 3개의 도형을 클래스로 정의하였다.
모든 클래스가 Comparable 모듈을 인클루드하고 있고 <=> 연산자를 정의하고 있다.
<=> 연산자의 코드를 보면 현재 객체와 비교 대상 객체에 대해 area 메서드를 호출하여 받은 값을 다시 <=> 연산자로 비교하고 있다.
Integer와 Float 클래스에서 <=> 연산자를 잘 정의해 놓았기 때문에 우리는 그냥 믿고 사용하면 된다.
class Rectangle
include Comparable
def initialize(width, height)
@width = width
@height = height
end
def perimeter
(@width + @height) * 2
end
def area
@width * @height
end
def <=>(other)
area <=> other.area
end
end
class RightTriangle
include Comparable
def initialize(base, height)
@base = base
@height = height
@hypotenuse = Math.sqrt((base ** 2) + (height ** 2))
end
def perimeter
@base + @height + @hypotenuse
end
def area
0.5 * @base * @height
end
def <=>(other)
area <=> other.area
end
end
class Circle
include Comparable
def initialize(radius)
@radius = radius
end
def perimeter
2 * Math::PI * @radius
end
def area
Math::PI * (@radius ** 2)
end
def <=>(other)
area <=> other.area
end
end
직각삼각형을 나타내는 RightTriangle 클래스의 코드를 보면 피타고라스의 정리를 이용해 빗변을 구하기 위해 Math 모듈의 sqrt 메서드를 사용하고 있다.
'sqrt'는 'square root'의 약자로서 인수로 건넨 숫자의 제곱근을 돌려준다.
그리고 Circle 클래스에서는 원의 둘레 길이와 넓이 계산을 위해 Math 모듈에 정의된 PI(원주율) 상수의 값을 사용하고 있다.
그런데 앞의 코드를 다시 보면 클래스마다 <=> 연산자의 코드가 동일하게 반복되어 있는 걸 볼 수 있다.
아래처럼 Shape 클래스를 만들고 Rectangle, RightTriangle, Circle 세 개의 클래스가 Shape 클래스를 상속하도록 코드를 수정해 보자.
Shape 클래스는 Comparable 모듈을 인클루드하고 있고 <=> 연산자도 직접 정의해 놓았기 때문에 Shape 클래스를 상속 받는 클래스는 area 메서드만 제공해 주면 객체 간의 비교가 가능해진다.
그리고 Shape 클래스에 둘레의 길이와 넓이를 나타내는 메서드도 정의해 놓았는데, 실제 둘레의 길이와 넓이를 구할 방법을 모르기 때문에 Shape를 상속 받는 클래스가 직접 구현하도록 NotImplementedError 예외를 발생하게 해 놓았다.
class Shape
include Comparable
def perimeter
raise NotImplementedError
end
def area
raise NotImplementedError
end
def <=>(other)
area <=> other.area
end
end
class Rectangle < Shape
def initialize(width, height)
@width = width
@height = height
end
def perimeter
(@width + @height) * 2
end
def area
@width * @height
end
end
class RightTriangle < Shape
def initialize(base, height)
@base = base
@height = height
@hypotenuse = Math.sqrt((base ** 2) + (height ** 2))
end
def perimeter
@base + @height + @hypotenuse
end
def area
0.5 * @base * @height
end
end
class Circle < Shape
def initialize(radius)
@radius = radius
end
def perimeter
2 * Math::PI * @radius
end
def area
Math::PI * (@radius ** 2)
end
end
테스트를 위해 먼저 앞의 코드를 D:/blog/ruby/comparable 폴더 아래 shapes.rb 파일에 저장하자.
그리고 해당 폴더의 위치에서 irb를 실행한 후 아래 그림처럼 도형의 둘레 길이와 넓이를 계산하는 간단한 기능부터 테스트해 보자.
세 클래스 모두 ancestors 메서드의 결과 배열에 Shape 클래스와 Comparable 모듈이 포함된 게 보이고, 둘레 길이와 넓이의 계산이 잘 되는 것도 볼 수 있다.
그러면 이번에는 아래 그림처럼 비교 연산자를 사용하여 도형들끼리 크기를 비교해 보자.
직사각형 객체 두 개와 직각삼각형 객체 하나 그리고 Circle 객체 하나를 생성했는데, 원을 제외한 나머지 도형 객체들은 같은 넓이가 되는 폭과 높이 그리고 밑변의 길이를 인수로 주고 생성하였다.
예상대로 넓이가 같은 두 개의 직사각형 r1과 r2를 == 연산자로 비교하니 true가 나왔고, 또 모양은 다르지만 넓이가 같은 직사각형 r1과 직각삼각형 t도 == 연산자로 비교하니 true가 잘 나왔다.
그리고 원 c보다 넓이가 작은 직사각형 r1에 대해 r1 < c 의 결과 역시 true로 잘 나온 것을 볼 수 있다.
이번에는 마지막으로 배열에 직사각형 객체를 여러 개 담아 넓이와 둘레의 길이를 기준으로 정렬을 해보자.
아래 그림을 보면 직사각형 객체 10 개가 담긴 배열을 만들었는데, 각 직사각형 객체는 가로와 세로의 길이를 각각 1부터 10까지의 정수 중 랜덤하게 선택해서 생성했다.
배열의 map 메서드를 사용하여 각 직사각형의 넓이를 보면 정렬이 되지 않은 상태로 보이는데, 배열의 sort 메서드로 정렬을 한 후 다시 map 메서드로 각 직사각형의 넓이를 보면 넓이를 기준으로 정렬이 잘 되어 있는 게 보인다.
배열의 sort 메서드는 기본으로 <=> 연산자를 통해 요소들을 비교하여 정렬을 하는데, Rectangle 클래스가 상속 받고 있는 Shape 클래스에서 <=> 연산자를 넓이(area)를 비교하는 것으로 구현해 놓았기 때문이다.
그리고 다시 map 메서드로 각 직사각형의 둘레의 길이를 확인해 보면 넓이와는 다른 숫자 10 개가 정렬이 안된 상태로 보이는데, sort_by 메서드를 사용하여 둘레의 길이(perimeter)를 기준으로 정렬이 되도록 한 후에 map 메서드로 확인해 보면
둘레의 길이를 기준으로 정렬이 된 것을 볼 수 있다.
끝으로 루비에는 추상 클래스와 추상 메서드를 정의할 수 있는 기능이 따로 없어서, 하위 클래스에서 구현해야 할 메서드를 Shape 클래스에서처럼 NotImplementedError 예외를 발생시키도록 작성하는 경우가 많이 있다.
만약 그렇게 작성해야 할 메서드가 많다면 그러한 코드들 때문에 코드의 전체적인 가독성이 떨어지게 되고, 불필요하게(?) 코드의 크기도 커지게 된다.
이전 글들에서 동일한 패턴의 본문을 갖는 메서드들을 쉽게 작성하는 것에 대해 몇 번 얘기를 했었는데, getter와 setter를 attr_reader, attr_writer, attr_accessor를 사용하여 손쉽게 만들 수 있다는 것이 그중 하나였다.
그리고 define_method를 사용해서 attr_reader, attr_writer, attr_accessor와 유사하게 동적으로 메서드를 생성해 보기도 했었다.
비슷한 방법으로 define_method를 사용해서 아래 Abstract 모듈의 def_abstract 메서드처럼 추상 메서드를 정의할 수 있는 기능을 어렵지 않게 만들 수 있다.
Shape 클래스는 Abstract 모듈을 extend로 인클루드한 후 def_abstract 메서드를 사용하여 손쉽게 area와 perimeter 두 개의 추상 메서드를 정의하였다.
물론, 하위 클래스에서 해당 메서드를 재정의하도록 유도를 할 뿐이지 Java와 같은 정적 타입 언어에서처럼 강제하지는 않는다.
Java에서는 추상 클래스를 상속하여 구현 클래스(Concrete Class)를 만들 때 구현하지 않은 추상 메서드가 하나라도 있으면 컴파일 자체가 안된다.
그러나 루비에서는 def_abstract 메서드를 통해 만든 추상 메서드가 실제 호출만 되지 않는다면 실행하는데 문제가 되지는 않는다.
module Abstract
def def_abstract(*method_names)
method_names.each do |method_name|
define_method(method_name) do
raise NotImplementedError.new("Method '#{method_name}' should be implemented")
end
end
end
end
class Shape
extend Abstract
include Comparable
def_abstract :area, :perimeter
def <=>(other)
area <=> other.area
end
end
class Square < Shape
def initialize(side)
@side = side
end
def area
@side ** 2
end
end
sq = Square.new(5)
sq.area
sq.perimeter
테스트를 위해 앞의 코드를 D:/blog/ruby/comparable 폴더 아래 test_abstract.rb 파일에 저장한 후 해당 폴더에서 cmd 창을 열어 test_abstract.rb 를 실행해 보자.
결과를 보면 정사각형의 넓이 25는 잘 출력되지만 둘레의 길이를 알기 위해 호출한 코드에서는 NotImplementedError 예외가 발생하는 걸 볼 수 있다.
앞의 코드를 보면 알겠지만, 정사각형 Square 클래스를 정의할 때 상속받은 Shape 클래스에서 추상 메서드로 정의한 두 메서드 중 area 메서드 하나만 정의하고 perimeter 메서드는 정의하지 않았기 때문이다.
See you again~~