[Java] 11. String의 hashCode과 equals, 그리고 toString의 재정의(override)


Study/Java  2020. 5. 8. 14:05

안녕하세요. 명월입니다.


이 글은 Java에서 String의 hashCode과 equals, 그리고 toString의 재정의(override)에 대한 글입니다.


이전에 제가 메모리 할당에 대해서 설명할 때 메모리 주소에 관해서 hashCode를 설명한 적이 있습니다.

링크 - [Java] 10. 메모리 할당(stack 메모리와 heap 메모리 그리고 new)과 Call by reference(포인터에 의한 참조)


이 hashCode는 메모리의 주소 값을 해시한 것으로 할당된 메모리마다 다르게 표시된다.(해시 알고리즘의 한계로 다른 메모리에 할당되어도 해시 값이 같을 수 있습니다. 아주 적은 확율로..)라고 설명했었습니다.

// 클래스
public class Example {
  // 변수
  private int data;
  // 생성자
  public Example(int data) {
    // 변수 값 설정
    this.data = data;
  }
  // 출력 함수
  public Example print() {
    // 콘솔 출력
    System.out.println("data - " + this.data);
    // 자기 자신 클래스를 리턴
    return this;
  }
  // 실행 함수
  public static void main(String... args) {
    // Example 클래스 선언
    Example ex1 = new Example(1);
    Example ex2 = new Example(1);
    // 콘솔 출력
    System.out.println("ex1 hashCode - " + ex1.hashCode());
    System.out.println("ex2 hashCode - " + ex2.hashCode());
  }
}

그럼 String 타입의 변수의 hashCode를 살펴보겠습니다.

// 클래스
public class Example {
  // 실행 함수
  public static void main(String... args) {
    // 변수 선언
	String a = "hello world";
	String b = "hello world";
	String c = new String("hello world");
    // 콘솔 출력
    System.out.println("a hashCode - " + a.hashCode());
    System.out.println("b hashCode - " + b.hashCode());
    System.out.println("c hashCode - " + c.hashCode());
  }
}

같은 값의 String값을 넣은 변수는 hashCode가 같습니다. a와 b는 같이 큰 따옴표로 처리해서 같다고 쳐도 new 선언한 String도 hashCode가 같게 나옵니다.

이게 저도 예전에도 궁금해서 찾아 보았고 지금도 찾아 보았는데 명쾌한 해설은 없네요. 제 경험으로 생각하면 다음과 같습니다.


먼저 가장 쉽게 String의 hashCode를 보면 Object 클래스에서 String은 hashCode함수를 재정의했습니다.

String은 문자열은 자바에서 byte(unsigned char)의 배열 형식입니다.

즉, String a값은 a[0] = 'h', a[1] = 'e', a[2] = 'l', a[3] = 'l', a[4] = 'o' ..로 이루어져 있습니다.

그런데 이 h는 아스키 코드로 정수 104의 값이고 e는 정수의 101의 값입니다.

이 문자 하나하나의 값은 정의되어 있고 이건 변하지 않는 값입니다. 이것을 hash로 나열하게 되면 "s[0]*31^(n - 1) + s[1]*31^(n - 2) + ... + s[n - 1]"의 형태 계산되어 hashCode가 만들어집니다.

링크 - https://www.tutorialspoint.com/java/java_string_hashcode.htm

다시 이걸 프로그램으로 보기 쉽게 계산하면 다음과 같습니다.

// 클래스
public class Example {
  // 실행 함수
  public static void main(String... args) {
    // 변수 선언 - 값이 커지면 overflow로 값이 다르게 나올 수 있기 때문에 작은 값으로 표현한다.
    String a = "hello";
    // 콘솔 출력
    System.out.println("a hashCode - " + a.hashCode());
    // a를 char 배열로.
    char[] c = a.toCharArray();
    // 해쉬 코드 계산
    int hashCode = calcHashCode(c, c.length - 1, 0);
    // 콘솔 출력
    System.out.println("calc hashCode = " + hashCode);
  }
  // 해쉬 값 계산하기.
  public static int calcHashCode(char[] array, int p, int n) {
    if (p < 0) {
      return 0;
    }
    // s[0]*31^(n - 1) + s[1]*31^(n - 2) + ... + s[n - 1]
    return array[p] * (int) Math.pow(31, n) + calcHashCode(array, p - 1, n + 1);
  }
}

이것이 String에서 hashCode를 하면 같은 값이 나오는 이유입니다.


제가 사실 예전에 이것때문에 오랫동안 Java를 해멘 적이 있습니다.hashCode함수는 메모리 주소 값이다 아니다로 말이죠. 이게 별 것 아닌 것 같지만, 위의 String a값을 비교할 때 a == "hello"가 true 값일까, false값일까의 문제로 바뀝니다. 왜냐하면 ==은 클래스의 메모리 주소를 비교를 해서 값이 같은가를 확인하는 것이기 때문입니다.

그래서 hashCode는 메모리 주소 값이고 String의 값은 리터널 값이기 때문에 항상 같은 값을 내보낸다고 잘못 생각한 적이 있었습니다.

그렇게 생각할 경우 모순이 되는게 그렇다면 같은 String 값을 선언한 것은 같다고 이해가 되도, concat으로 문자열을 합치거나 프로그램을 다시 시작해도 그 값이 같다는게 설명이 되지 않습니다.

그러면 java의 hashCode는 주소 값이 아닌가? 사실 주소값이라고 표현한 적은 없지만 클래스마다 각자 고유의 값을 가지고 있는 것은 맞습니다.(Object에서 재정의를 하지 않을 경우)

알고보니 String이 hashCode함수를 재정의했습니다. hashCode 함수를...하........

그래서 hashCode가 주소 값이다. 아니다 말이 많은 것 같습니다. 제 생각에는 정확하게 주소 값은 아니지만 주소 값만큼의 클래스의 고유의 값인 것은 맞습니다.

재.정.의.를. 하.지.않.는.다.면. 말이지요


여기서 equals는 아시다시피 클래스를 비교하는 함수입니다.

// 클래스
public class Example {
  // 변수
  private int data;
  // 생성자
  public Example(int data) {
    // 변수 값 설정
    this.data = data;
  }
  // 출력 함수
  public Example print() {
    // 콘솔 출력
    System.out.println("data - " + this.data);
    // 자기 자신 클래스를 리턴
    return this;
  }
  // Object 클래스에서 재정의했다.
  @Override
  public boolean equals(Object val) {
    // 비교하는 클래스가 Example 클래스가 아니면 false
    if(val.getClass() != Example.class) {
      return false;
    }
    // Example 클래스이고
    Example d = (Example)val;
    // 값이 같으면 true, 다르면 false
    return d.data == this.data;
  }

  // 실행 함수
  public static void main(String... args) {
    // Example 클래스 선언
    Example ex1 = new Example(1);
    Example ex2 = new Example(1);
    // 콘솔 출력
    System.out.println("ex1 hashCode - " + ex1.hashCode());
    System.out.println("ex2 hashCode - " + ex2.hashCode());
    // ==를 통해 비교하는 경우
    System.out.println("ex1 == ex2 - " + (ex1 == ex2));
    // equals 함수를 비교하는 경우.
    System.out.println("ex1 equals ex2 - " + (ex1.equals(ex2)));
  }
}

위 예를 보면 관계 연산자 ==과 equals이 다른 결과를 내보냅니다. 저는 여기서 equals 함수를 재정의 했습니다.

equals를 사용하게 되면 같은 Example 클래스이고 값이 같으면 true를 내보냅니다. 즉, 같은 인스턴스인지(주소 값이 같은)를 비교하는 것이 아니고 값이 같으면 true를 내보낼 수 있습니다.

그냥 비교 연산자 사용할 경우는 Heap에 있는 인스턴스가 같은 것을 가르키고 있는지를 확인하는 것입니다.


다시 String으로 돌아옵니다.

// 클래스
public class Example {
  // 실행 함수
  public static void main(String... args) {
    // 변수 선언
    String a = new String("hello world");
    String b = "hello world";
    String c = "hello world";
    // 콘솔 출력
    System.out.println("a == b - " + (a == b));
    System.out.println("a equals b - " + (a.equals(b)));
    System.out.println("b == c - " + (b == c));
  }
}

위 결과를 보면 a와 b는 관계 연산자로 비교를 했을 때 false를 내보냅니다. 뭐 위의 논리로는 클래스가 다르게 선언되었으니 다른거라고 생각할 수 있습니다. 그래서 String을 비교할 때는 equals를 사용해야 맞습니다.

그런데 여기서 사람을 혼란에 빠지게 하는 게 b == c와 같은 경우입니다. 다르게 선언되었는데 또 b == c가 결과가 같습니다. 그래서 우리는 아 문자열로 비교를 하는데, 될 때가 있고 안 될 때도 있구나라고 생각합니다.

제 생각에는 리터널의 차이인 것 같습니다. C/C++에서 char[]와 const char*의 차이라고나 할까?


정리하면 String을 비교할 때는 반드시 equals 함수를 사용해야 합니다.


여기까지 제가 hashCode와 equals의 함수에 대해 설명했습니다.


Java에서는 원시 데이터 타입이 아닌 이상, 모든 클래스는 Object 클래스를 상속받습니다.

상위 클래스에서 사용되는 함수를 재정의 할 때는 어트리뷰트 @Override를 사용하면 재정의가 됩니다. 여기서 재정의란 상위의 클래스에서 선언된 함수를 상속받으면서 다시 재선언하는 것입니다.

즉, 해당 클래스에서 할당하여 사용하게 되면 재 선언한 함수가 호출됩니다.

// 클래스
public class Example {
  // 변수
  private int data;

  // 생성자
  public Example(int data) {
    // 변수 값 설정
    this.data = data;
  }
  // toString 함수 재정의
  @Override
  public String toString() {
    // 콘솔 출력
    System.out.println("data - " + data);
    // toString 결과 리턴
    return "Hello world";
  }
  // 실행 함수
  public static void main(String... args) {
    // Example 클래스 선언
    Example ex1 = new Example(1);
    // toString 결과 출력
    System.out.println("ex1.toString - " + ex1.toString());
  }
}

여기까지 Java에서 String의 hashCode과 equals, 그리고 toString의 재정의(override)에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.