이번 글에서는 해시의 키로 사용할 수 있는 객체의 클래스를 직접 만들어 보려고 한다.
아래 Person 클래스의 코드를 보면 name과 birthday 두 개의 속성을 초기화할 수 있는 initialize 메서드를 정의하였고, attr_reader를 사용하여 두 속성에 대한 getter 메서드를 추가하였다.
class Person
attr_reader :name, :birthday
def initialize(name, birthday)
@name = name
@birthday = birthday
end
end
아직 Person 클래스에 hash 메서드와 eql? 메서드를 정의하지 않았는데, 우선 현재의 코드 상태로 Person 클래스의 객체를 해시 키로 사용하면 어떻게 되는지 살펴보자.
테스트를 위해 Person 클래스의 코드를 D:/blog/ruby/hash 폴더 아래 person.rb 파일로 저장하자.
그리고 같은 폴더의 위치에서 irb를 실행한 후 아래 그림처럼 코드를 입력하고 결과를 확인해 보자.
이전 글을 봤다면 이미 결과를 예상했을 수 있다.
Person 클래스는 hash 메서드를 정의하지 않았기 때문에 Kernel 모듈에서 정의한 hash 메서드를 사용하게 되는데, Kernel 모듈에서는 hash 메서드를 객체마다 다른 값을 돌려주도록 정의해 놓았다.
그래서 두 Person 객체가 서로 같은 값을 갖는 객체라도 hash 메서드의 결괏값(이하 hash 값)은 다르기 때문에 해시 테이블에서 서로 다른 버킷을 찾게 될 가능성이 높다.
설사 운 좋게(?) 같은 버킷을 찾았다고 해도 두 Person 객체를 eql? 메서드로 같은지 비교하면 결과가 false이므로 결국 두 Person 객체는 서로 다른 키로 간주된다.
eql? 메서드 역시 Kernel 모듈에서 정의한 eql? 메서드를 호출하게 되고, Kernel 모듈에서는 동일한 객체일 경우에만 true를 반환하도록 eql? 메서드를 정의해 놓았다.
그럼 이제 아래 코드처럼 Person 클래스 안에 hash 메서드와 eql? 메서드 두 개를 추가해 보자.
class Person
attr_reader :name, :birthday
def initialize(name, birthday)
@name = name
@birthday = birthday
end
def hash
[name, birthday].hash
end
def eql?(other)
other.is_a?(Person) && name == other.name && birthday == other.birthday
end
end
hash 메서드는 두 속성의 값을 담은 배열에 대해 hash 메서드를 호출하고 그 결괏값을 그대로 반환하도록 정의했다.
Array 클래스는 모든 요소들의 hash 값을 사용해서 최종 hash 값을 계산하도록 Kernel 모듈의 hash 메서드를 재정의 하였다.
아래 그림을 보면 Foo 클래스의 객체를 요소로 갖는 배열에 대해 hash 메서드를 호출하였을 때 Foo 클래스에서 정의한 hash 메서드가 호출되는 것을 볼 수 있다.
루비에서 미리 구현해 놓은 것을 잘 활용하면 필요한 것을 쉽고 빠르게, 그리고 안전하게 만들 수 있다.
Person 클래스의 eql? 메서드를 보면 먼저 is_a? 메서드를 사용해 Person 클래스 또는 그 하위 클래스의 객체인지 확인하고 그다음 name과 birthday 속성의 값이 같은지 확인한다.
수정한 Person 클래스의 코드를 person.rb 파일에 저장하고 irb를 다시 실행하여 똑같은 코드를 테스트해 보자.
이번에는 해시에 Person 객체(a)를 키로 저장한 값을 또 다른 Person 객체(b)를 키로 사용하여 해당 값을 조회할 수 있는 것을 볼 수 있다.
그리고 당연히 두 Person 객체는 hash 값이 같고, eql? 메서드도 true를 돌려준다.
직접 만든 클래스의 객체를 해시 키로 사용하기 위해 해당 클래스에 hash와 eql? 메서드를 구현할 때 충족해야 할 몇 가지 조건이 있다.
해당 클래스의 객체 a, b, c 가 있을 때, a.eql?(b)가 true 면 a.hash와 b.hash의 값도 같아야 하고, a.eql?(b)가 true 면 그 반대인 b.eql?(a)도 true 여야 하며, a.eql?(b)와 b.eql?(c)가 모두 true 면 a.eql?(c)도 true 여야 한다.
이 조건을 만족시키지 못했을 때 어떻게 되는지 확인해 보기 위해 Person 클래스의 hash 메서드와 eql? 메서드를 각각 수정한 후 테스트 해보자.
먼저 hash 메서드를 아래 코드처럼 객체마다 다른 값을 반환하도록 object_id 메서드를 사용하여 수정하자.
class Person
attr_reader :name, :birthday
def initialize(name, birthday)
@name = name
@birthday = birthday
end
def hash
object_id.hash
end
def eql?(other)
other.is_a?(Person) && name == other.name && birthday == other.birthday
end
end
이제 테스트를 위해 수정한 Person 클래스의 코드를 person.rb 파일에 저장한 후 해당 위치에서 irb를 실행한 다음 아래 그림처럼 코드를 입력해 보자.
이름과 생일이 모두 같은 Person 객체 두 개를 생성하여 각각 변수 a와 b에 할당한 후 eql? 메서드로 비교해 보면 결과가 true가 나오지만 예상대로 hash 값은 서로 다른 걸 볼 수 있다.
첫 번째 조건을 만족시키지 못한 경우이고 a와 b는 비록 값이 같은 객체이지만 hash 값이 달라 서로 다른 키로 간주된다.
그래서 해시에 a를 키로 저장한 값을 b를 키로 사용해서 찾을 수 없고 그 반대의 경우도 마찬가지이다.
이번에는 Person 클래스에서 hash 메서드의 코드는 원래대로 되돌리고 eql? 메서드에 object_id 값에 대한 비교를 추가해 보자.
class Person
attr_reader :name, :birthday
def initialize(name, birthday)
@name = name
@birthday = birthday
end
def hash
[name, birthday].hash
end
def eql?(other)
other.is_a?(Person) && name == other.name && birthday == other.birthday && object_id < other.object_id
end
end
다시 Person 클래스의 코드를 person.rb 파일에 저장한 후 irb를 실행한 다음 아래 그림처럼 코드를 입력해 보자.
이름과 생일이 모두 같은 Person 객체 두 개를 생성하여 각각 변수 a와 b에 할당한 후 eql? 메서드로 a와 b를 비교해 보면 b.eql?(a)는 true 지만 그 반대인 a.eql?(b)는 false인 것을 볼 수 있다.
두 번째 조건을 만족시키지 못한 경우로서, 해시에 a를 키로 저장한 값을 b를 키로 사용해서 찾을 수는 있었지만 반대로 b를 키로 저장한 값을 a를 키로 사용해서는 찾을 수 없었다.
직접 만든 클래스의 객체를 해시 키로 사용하면서 예상치 못한 동작을 피하고 싶다면 앞에서 설명한 조건들을 충족하도록 hash와 eql? 메서드를 구현해야 한다.
해시 키로 사용할 객체는 값이 변하지 않는 객체를 사용하는 것이 좋다고 했고, Person 클래스 역시 속성값을 변경할 수 있는 메서드를 제공하지 않고 있다.
당연한 얘기지만 이미 해시의 키로 사용하고 있는 Person 객체의 값을 변경하게 되면 그 객체를 키로 사용해서 저장해 놓았던 값을 조회할 수 없게 된다.
확인을 위해 Person 클래스의 속성값을 변경할 수 있도록 attr_reader를 attr_accessor로 수정하자.
class Person
attr_accessor :name, :birthday
def initialize(name, birthday)
@name = name
@birthday = birthday
end
def hash
[name, birthday].hash
end
def eql?(other)
other.is_a?(Person) && name == other.name && birthday == other.birthday
end
end
수정한 Person 클래스의 코드를 person.rb 파일에 저장하고 해당 폴더의 위치에서 irb를 실행한 후 아래 그림처럼 코드를 입력해 보자.
변수 a와 b에 이름과 생일이 각기 다른 Person 객체를 생성하여 할당했는데, 당연히 a와 b의 hash 값은 서로 다르고 eql? 메서드로 비교한 결과도 false가 나왔다.
그다음 a와 b를 키로 하여 해시에 값을 저장한 다음, a 변수가 참조하는 Pesron 객체의 name 속성의 값을 b 변수가 참조하는 Person 객체의 name 속성의 값과 같도록 설정하였다.
다시 a의 hash 값을 확인해 보면 처음 a의 hash 값과 달라진 것을 볼 수 있고 a를 키로 더 이상 해시에서 값을 조회할 수 없게 되었다.
그런데, a 변수가 참조하는 Pesron 객체의 birthday 속성의 값도 b 변수가 참조하는 Person 객체의 birthday 속성의 값과 같도록 설정하면, a와 b의 hash 값이 같게 되고 eql? 메서드로 비교하면 true가 나온다.
따라서 b를 키로 해시에 저장한 값을 a를 키로 사용해서도 조회할 수 있게 되었다.
끝으로 아래 코드처럼 Person 클래스를 상속하여 Programmer 클래스를 만들어 Programmer 객체를 해시 키로 사용해 보자.
Programmer 클래스에는 language 속성을 하나 추가하였고, initialize 메서드에서 super 키워드를 사용하여 name과 birthday 속성값을 초기화할 수 있게 했다.
require './person'
class Programmer < Person
attr_reader :language
def initialize(name, birthday, language)
super(name, birthday)
@language = language
end
end
Programmer 클래스의 코드를 D:/blog/ruby/hash 폴더 아래 programmer.rb 파일에 저장하고, Person 클래스의 코드도 아래처럼 원래대로 되돌린 후 person.rb 파일에 저장하자.
class Person
attr_reader :name, :birthday
def initialize(name, birthday)
@name = name
@birthday = birthday
end
def hash
[name, birthday].hash
end
def eql?(other)
other.is_a?(Person) && name == other.name && birthday == other.birthday
end
end
이제 D:/blog/ruby/hash 폴더의 위치에서 irb를 실행하여 아래 그림처럼 코드를 입력하고 결과를 확인해 보자.
이름과 생일이 모두 같은 Person 클래스의 객체와 Programmer 클래스의 객체에 대해 hash 값을 확인해 보니 값이 서로 같고 eql? 메서드로 비교하면 true가 나오는 것을 볼 수 있다.
따라서 두 객체는 같은 해시 키로 사용할 수 있고, 실제로 해시에 programmer를 키로 하여 저장한 값을 person을 키로 사용해서 값을 조회할 수 있었다.
이것이 가능한 이유는 Programmer 클래스가 Person 클래스를 상속받아 구현하면서 Person 클래스에 정의된 hash 메서드와 eql? 메서드를 재정의하지 않았기 때문이다.
그리고 Person 클래스에서 정의한 eql? 메서드의 코드를 다시 보면 is_a? 메서드를 사용해서 Person 클래스를 상속한 클래스의 객체까지도 허용한 것을 볼 수 있다.
만약 Programmer 클래스의 객체를 해시 키로 사용할 때 Person 클래스의 객체로는 조회할 수 없게 하고 싶다면 Programmer 클래스에서 hash 메서드와 eql? 메서드를 재정의하면 된다.
아래 수정한 Programmer 클래스의 코드를 보자.
hash 메서드는 super를 통해 Person 클래스에서 정의한 hash 메서드를 호출하여 받은 값과 language 속성의 값을 배열에 담아 배열의 hash 값을 반환하도록 재정의했다.
eql? 메서드는 먼저 비교 대상 객체가 Programmer 클래스 또는 그 하위 클래스의 객체인지 확인한 다음 맞는다면 super를 통해 Person 클래스에서 정의한 eql? 메서드로 비교를 하고 결과가 true 면 마지막으로 language 속성의 값이 같은지 비교하도록 재정의했다.
require './person'
class Programmer < Person
attr_reader :language
def initialize(name, birthday, language)
super(name, birthday)
@language = language
end
def hash
[super, language].hash
end
def eql?(other)
other.is_a?(Programmer) && super && language == other.language
end
end
테스트를 위해 수정한 Programmer 클래스의 코드를 D:/blog/ruby/hash/programmer.rb 파일에 저장하고 다시 irb를 실행한 후 아래 그림처럼 코드를 입력해 보자.
결과를 보면 Person 클래스의 객체와 Programmer 클래스의 객체가 서로 다른 hash 값을 돌려주고 eql? 메서드로 비교하면 false가 나오는 것을 볼 수 있다.
그래서 Programmer 클래스의 객체를 키로 하여 해시에 저장한 값을 Person 클래스의 객체를 키로 사용해서 조회할 수 없게 되었다.
그리고 person.eql?(programmer)는 true 지만, 그 반대인 programmer.eql?(person)은 false가 되어 Person 클래스에서 정의한 eql? 메서드는 충족되었던 조건이 더 이상 유지되지 않게 되었다.
그런데, Programmer 클래스도 eql? 메서드에서 is_a? 메서드를 사용하여 Programmer 클래스를 상속한 클래스의 객체까지도 허용해 놓았기 때문에 Programmer 클래스를 상속받은 클래스에서 hash 메서드와 eql? 메서드를 재정의하지 않는다면 값이 같은 객체끼리는 같은 해시 키로 사용할 수 있게 된다.
결국, 상위 클래스와 하위 클래스의 객체를 같은 키로 사용할지에 따라 구현 방향을 결정하면 된다.
See you agian~~