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

해시와 친해지기1

by 경자꿈사 2024. 11. 22.

이번 글에서는 데이터를 저장하기 위해 배열과 함께 기본적으로 많이 사용하는 해시에 대해 조금은 더 깊이 알아보려고 한다.
해시는 키-값 쌍으로 데이터를 저장하고 싶을 때 사용하는데, 어떠한 객체라도 해시에 값으로 넣을 수 있고, 루비에서 기본으로 제공하는 객체 대부분을 키로 사용할 수 있다.
아래 그림을 보면 nil을 키로 사용할 수도 있고, 심지어 해시도 다른 해시의 키로 사용할 수 있다.

그러나, 해시의 키로는 값이 변하지 않는 객체를 사용하는 것이 좋다. 그래서 가능하면 심볼을 해시의 키로 많이 사용하는데, 리터럴 형식의 정수나 문자열도 키로 사용하기에 나쁘지 않다.
아래 그림을 보면 키로 사용한 해시 h에 값을 넣어 해시 h가 변경이 되면, 그 해시 h를 키로해서 해시 h2에서 다시 값을 찾을 수 없는 것을 볼 수 있다.

연속된 정수를 키로 하여 해시를 사용하려고 할 때 특별한 이유가 없다면 해시 대신 배열을 사용하는 것도 고려해 보자.
배열을 사용하면 음의 정수를 인덱스로 하여 역방향으로도 요소를 조회할 수 있다.
아래 그림을 보면 h[-1]은 nil을 반환하지만 arr[-1]은 "Ruby" 문자열을 반환해 주는 걸 볼 수 있다.

'로또 당첨 번호 출현 빈도 계산하기'글을 보면 연속된 정수를 키로 갖는 해시를 사용했을 때 배열보다 더 쉽게 프로그램을 작성할 수 있었다.
아래 그림을 보면 Here Document를 사용하여 로또 당첨 번호 10 개를 담은 문자열을 만든 후, split 메서드를 사용하여 정수 문자열을 담은 배열(["6", "16",...])을 얻고 다시 map 메서드를 통해 각 정수 문자열을 정수로 변환한 배열([6, 16, ...])을 얻었다.
그리고 each 메서드의 블록을 통해 해시 h에서 각각의 정수를 키로 갖는 값을 1씩 증가시켰다.
변수 h는 해당하는 키가 없을 때 기본값으로 0을 반환하는 해시(Hash.new(0))를 참조하고 있기 때문에, 예외가 발생할 염려 없이 h[n] += 1 이렇게 작성할 수 있다. 
마지막 코드는 가장 많이 출현한 번호 3개를 알아보기 위해 sort_by 메서드를 사용하여 출현 횟수를 나타내는 값을 기준으로 내림차순 정렬하였다.

아래 그림을 보면 앞의 예제를 해시 대신 배열을 사용해서 작성하였다.
모든 요소가 0으로 초기화된 크기가 46인 배열을 만들어서 사용하면 집계 코드는 해시를 사용할 때와 달라질 게 없지만 가장 많이 출현한 번호 3개를 알아 내기 위해서는 해시보다 조금 더 수고가 필요하다.
배열을 그대로 정렬해 버리면 출현한 숫자가 어떤 숫자인지에 대한 정보가 사라져 버려 each_with_index 메서드를 사용하여 횟수와 숫자를 짝지어줘야 한다.
그리고 숫자와 횟수 순서로 결괏값이 출력되도록 마지막에는 map과 reverse 메서드를 사용하여 순서를 바꾸는 게 필요했다.
자세한 내용은 '로또 당첨 번호 출현 빈도 계산하기'글을 참고하길 바란다.

이제 해시의 기본적인 기능과 유용한 기능 몇 가지를 살펴보자.
먼저 keys 메서드는 해시의 모든 키를 담은 배열을 돌려주고 values 메서드는 모든 값을 담은 배열을 돌려준다.
특정 키로 해시의 값을 조회할 때는 배열과 마찬가지로 [] 연산자를 사용하면 되는데 해시도 배열처럼 없는 키에 대해서는 nil을 돌려준다.
그런데 키가 없을 때 단순히 nil이 아니라 예외를 발생시키거나 기본값을 돌려주도록 하고 싶다면 fetch 메서드를 사용하면 된다.
fetch 메서드를 호출할 때 인수로 키 하나만 주면, 키가 없을 때 KeyError 예외가 발생하고 두 번째 인수로 기본값을 제공해 주면 예외 대신 기본값을 돌려준다.

그리고, 아래 그림처럼 기본값을 지정하여 생성한 해시에 대해 존재하지 않는 키로 fetch 메서드를 호출할 경우, 두 번째 인수로 기본값을 주지 않았다면 여전히 예외를 발생시킨다.
만약 fetch 메서드에 기본값을 인수로 전달하면 해시 생성 시 지정했던 기본값보다 우선하여 그것을 반환한다.

해시 생성 시 인수로 기본값이 아니라 블록을 주면 키가 없을 때 해당 블록을 실행시켜 동적으로 값을 생성할 수도 있다.
아래 그림을 보면 해시 생성 시 블록을 전달했는데 블록이 실행될 때 h 파라미터에는 해시 객체가 전달되고 k 파라미터에는 키(존재하지 않는 키)가 전달된다.
블록의 내용을 보면 전달된 키의 값으로 빈 배열을 설정하고 있다.
h[:a] 코드를 처음 실행하면 아직 해시에 해당하는 키가 없기 때문에 블록이 실행되어 해시에는 심볼 :a를 키로 빈 배열이 설정되고, 결국 결괏값으로 빈 배열이 반환된다.
그리고 해시를 출력해 보면 정말 심볼 :a와 빈 배열이 쌍으로 잘 들어가 있는 게 보인다.
블록을 전달하여 생성한 해시에 대해서도 기본값을 인수로 주지 않고 fetch 메서드를 호출하면 키가 없을 때 KeyError 예외를 발생시키는 것을 볼 수 있다.

해시 생성 시 블록을 전달하는 방법을 사용하는 조금 더 실용적인 예제 두 가지를 살펴보자.

아래 그림은 노래 가사에 나오는 단어들을 길이를 기준으로 같은 배열에 모으는 작업을 하고 있다.
먼저 Here Document를 사용하여 노래 가사를 담은 문자열을 만들었다. 그리고 이 문자열에서 구두점들을 제거한 후 공백을 기준으로 문자열을 나눠 단어 배열을 만들고, map과 downcase 메서드를 사용하여 모든 단어를 소문자로 변환한 다음 
uniq 메서드를 사용하여 중복된 단어를 걸러냈다. 이제 집계를 위한 사전 작업은 완료되었으니 단어를 담은 배열에 대해 each 메서드를 사용하여 집계만 하면 된다. 
집계 데이터는 담을 해시를 키가 없을 경우 해당 키의 값으로 빈 배열을 설정하도록 만들었기 때문에, 해시에서 문자열(word)의 길이를 키로 하여 얻은 값(배열)에 해당 문자열을 추가하기만 하면 된다.
집계 결과를 담은 해시의 출력 내용을 보면 집계가 잘 된 것을 볼 수 있다.

아래는 이전 예제를 배열의 group_by 메서드를 사용하는 것으로 수정해 본 것이다.
코드가 더 간결해진 것을 볼 수 있다. 그러나 배열의 group_by 메서드를 사용하기 위해서는 집계 대상 데이터가 배열이어야 한다는 제약이 있고, 특정 예외 조건 등을 넣기가 쉽지 않을 수 있다.
이전 '파일 및 디렉터리 수 집계하기' 글에서 살펴본 Dir 클래스의 glob 메서드를 사용해 한 번에 집계하는 것과 직접 하위 디렉터리를 탐색해가면서 집계하는 것의 차이와 비슷한 면이 있다.
'파일 및 디렉터리 수 집계하기' 글을 참고해 보면 좋을 것 같다.

다음 예제는 데이터 베이스 또는 csv 파일 등에서 데이터를 읽어와 해시로 구성하는 예이다.
여기서는 간단히 하기 위해 Here Document를 사용하여 코드 데이터를 포함한 문자열을 만들었고, 라인 하나하나가 코드 데이터이므로 라인 단위로 처리를 하는 것이 편하다.
그래서 개행 문자로 문자열을 나누고 each 메서드를 사용하여 블록 안에서 작업을 처리하도록 하였다.
블록 내용을 보면 먼저 파라미터 e로 받은 문자열을 "," 을 구분자로 나누고 세 개의 변수에 순서대로 담는다.
그리고 코드 데이터를 담을 해시에서 big_code를 키로 해서 값(해시)을 가져온 후 code와 code_name 데이터 쌍을 저장한다.
코드 데이터를 담을 해시는 앞선 예제와 달리 키가 없을 때 해당 키로 또 다른 해시를 설정하도록 만들어 놓았다.
코드 데이터 저장이 끝난 후 해시의 출력 결과를 보면 중첩 해시의 형태로 코드 데이터가 잘 저장이 된 걸 볼 수 있다.
해시에서 대분류가 "JOB"인 코드 중 "02" 코드의 값을 가져오려면 h["JOB"]["02"] 이렇게 하면 되는데, Hash 클래스에는 이러한 중첩 해시에서 쉽게 값을 찾을 수 있게 dig 메서드를 만들어 놓았다.
아래 그림의 마지막 코드는 dig 메서드를 사용하여 대분류가 "JOB"인 코드 중 "02" 코드의 값을 가져왔다.

중첩 해시뿐만 아니라 값이 배열일 경우에도 dig 메서드를 사용하여 한 번에 조회할 수 있는데, 실제 dig 메서드를 호출할 수 있는 객체이기만 하면 뭐든 상관이 없다.
아래 그림을 보면 Hash 클래스뿐만 아니라 Array 클래스와 Struct 클래스에도 dig 인스턴스 메서드가 정의되어 있고, 배열 객체나 Struct 클래스를 통해 만든 Language 클래스의 객체에 대해 직접 dig 메서드를 호출할 수 있는 것을 볼 수 있다.
특히, Language 객체에 대해서는 name 속성의 값을 getter 메서드와 [] 연산자 그리고 dig 메서드 등 세 가지 방법을 사용해서 조회할 수 있는 것을 볼 수 있다.

 

기본값이나 블록을 꼭 해시 객체를 생성할 때만 설정할 수 있는 건 아니다. 해시 객체를 생성한 후에도 기본값이나 블록을 설정하거나 변경할 수 있다.
다만 생성 시나 생성 후 모두 기본값과 블록 중 하나만 설정이 가능하다.
아래 그림을 보면 기본값이나 블록 없이 해시를 생성한 후에 해시의 default 속성의 값을 0으로 설정하면 없는 키에 대해 0을 돌려주는 걸 볼 수 있다.
그리고 다시 동일한 해시에 대해 default_proc 속성의 값을 설정하면 키가 없을 때 default_proc 속성에 설정한 Proc 객체가 실행되는 걸 볼 수 있다.
그리고 default_proc 속성의 값을 설정하면 전에 설정했던 default 속성의 값이 nil로 초기화되는 걸 볼 수 있는데 반대의 경우도 마찬가지임을 알 수 있다.

이번 글은 여기서 마치고, 다음 글에서 해시의 또 다른 메서드부터 다시 알아보겠다.

See you again~~