Study/JAVA

[JAVA] 10장 - 클래스의 상속과 다형성 (2)

오구민 2022. 10. 4. 21:53

1. 메서드 오버라이딩

1.1 메서드 오버라이딩의 개념과 동작

  1.1.1 개념

  • 메서드 오버라이딩은 부모 클래스에게 상속받은 메서드와 동일한 이름의 메서드를 재정의하는 것으로, 부모의 메서드를 자신이 만든 메서드로 덮어쓰는 개념이다.

 

  1.1.2 조건

  • 부모 클래스의 메서드와 시그너처 및 리턴 타입이 동일해야 한다.
  • 부모 클래스의 메서드보다 접근 지정자의 범위가 같거나 넓어야 한다.

 

  1.1.3 덮어쓰기와의 관계

  • 덮어쓰기는 이전 파일이 완전히 삭제되고, 새로운 파일로 바뀌는 것.
  • 오버라이딩은 이전의 메서드 위에 새로운 메서드가 올라(over) 타고(riding) 있는 것.
  • 오버라이딩은 원할 때 밑에 깔려 있는 이전 객체의 메서드도 호출할 수 있다.

 

  1.1.4 동일한 필드나 메서드가 있을 경우

  • 객체 내에 동일한 필드 또는 메서드가 있는 경우, 참조 변수가 가리키는 객체의 바깥쪽부터 안쪽으로 들어가면서 만나는 첫 번째 멤버가 실행된다.

 

  1.1.5 예제

package java_study_001;

class A {
	void print() {
		System.out.println("A 클래스");
	}
}
class B extends A {
	@Override
	void print() {
		System.out.println("B 클래스");
	}
}
public class Study_001 {
	public static void main(String[] args) {
		// A타입 / A생성자
		A aa = new A();
		aa.print();
		
		// B타입 / B생성자
		B bb = new B();
		bb.print();
		
		// A타입 / B생성자
		A ab = new B();
		ab.print();
	}
}

 

1.2 메서드 오버라이딩을 사용하는 이유

다형적 표현을 사용해 각각의 자식 클래스 타입으로 객체를 생성하고, 부모 클래스 타입으로 선언한다.

서로 다른 출력 결과를 보이겠지만, 모든 객체가 부모 타입 하나로 선언되었기 때문에 관리가 쉽다.

 

  1.2.1 예제

package java_study_001;

class Animal {
	void cry() {}
}
class Bird extends Animal {
	@Override
	void cry() {
		System.out.println("짹짹");
	}
}
class Cat extends Animal {
	@Override
	void cry() {
		System.out.println("야옹");
	}
}
class Dog extends Animal {
	@Override
	void cry() {
		System.out.println("멍멍");
	}
}
public class Study_001 {
	public static void main(String[] args) {
		// 각각의 타입으로 선언 + 각각의 타입으로 생성
		Animal aa = new Animal();
		Bird bb = new Bird();
		Cat cc = new Cat();
		Dog dd = new Dog();
		aa.cry();
		bb.cry();
		cc.cry();
		dd.cry();
		System.out.println();
		
		//Animal 타입으로 선언 + 자식 클래스 타입으로 생성
		Animal ab = new Bird();
		Animal ac = new Cat();
		Animal ad = new Dog();
		ab.cry();
		ac.cry();
		ad.cry();
		System.out.println();
		
		//배열로 관리
		Animal[] animals = {ab, ac, ad};
		for(Animal animal : animals) {
			animal.cry();
		}
	}
}
//실행 결과
짹짹
야옹
멍멍

짹짹
야옹
멍멍

짹짹
야옹
멍멍

 

1.3 메서드 오버라이딩과 메서드 오버로딩

  1.3.1 개념

  • 오버로딩 : 시그너처가 다른 여러 개의 메서드를 같은 공간에 정의하는 것. 파일로 예시를 들면, 파일명은 동일하지만, 확장명이 다른 파일을 같은 폴더에 복사하는 것.
  • 오버라이딩 : 파일을 예시로 들면, 파일명과 확장명이 완벽하게 동일한 파일을 같은 공간에 복사하는 것. 덮어쓰기가 수행됨.

 

  1.3.2 예제

package java_study_001;

class A {
	void print1() {
		System.out.println("A 클래스 print1");
	}
	void print2() {
		System.out.println("A 클래스 print2");
	}
}
class B extends A {
	@Override
	void print1() {
		System.out.println("B 클래스 print1");
	}
	void print2(int a) {
		System.out.println("B 클래스 print2");
	}
}
public class Study_001 {
	public static void main(String[] args) {
		// A타입 선언 / A 생성자 사용
		A aa = new A();
		aa.print1();
		aa.print2();
		System.out.println();
		
		// B타입 선언 / B 생성자 사용
		B bb = new B();
		bb.print1();
		bb.print2();
		bb.print2(3);
		System.out.println();
		
		// A타입 선언 / B 생성자 사용
		A ab = new B();
		ab.print1();
		ab.print2();
	}
}
//실행 결과
A 클래스 print1

A 클래스 print2

B 클래스 print1
A 클래스 print2
B 클래스 print2

B 클래스 print1
A 클래스 print2

 

1.4 메서드 오버라이딩과 접근 지정자

자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때는 반드시 상속받은 메서드의 접근 지정자와 범위가 같거나 넓은 접근 지정자를 사용해야 한다.

즉, 접근 지정자의 범위를 좁힐 수 없다는 이야기다.

 

  1.4.1 메서드 오버라이딩할 때 사용할 수 있는 접근 지정자

부모 클래스 메서드의 접근 지정자 메서드 오버라이딩을 할 떄 사용할 수 있는 접근 지정자
public public
protected public, protected
default public, protected, default
private public, protected, default, private

 

  1.4.2 예제

package java_study_001;

class A {
	protected void abc() {}
}
class B1 extends A {
	public void abc() {}
}
class B2 extends A {
	protected void abc() {}
}
class B3 extends A {
	//void abc() {}
	//default 지정자(좁아져서 불가)
}
class B4 extends A {
	//private void abc() {}
	//private 접근 지정자(좁아져서 불가)
}
public class Study_001 {
	public static void main(String[] args) {
	}
}

 

 

2. 인스턴스 필드와 정적 멤버의 중복

인스턴스 필드나 정적 멤버(정적 필드와 정적 메서드)는 자식 클래스에서 동일한 이름으로 정의해도 오버라이딩되지 않는다.

 

2.1 인스턴스 필드의 중복

메서드의 경우 객체 내의 메서드 위치를 저장하는 공간은 분리돼 있지만, 실제 메서드가 저장되는 공간은 인스턴스 메서드 영역 한 곳이므로 오버라이딩이 발생한다.

하지만 인스턴스 필드는 상속받은 필드와 동일한 이름으로 자식 클래스에서 정의해도 각각의 저장 공간에 저장되므로 오버라이딩은 발생하지 않는다.

 

  2.1.1 예제

package java_study_001;

class A {
	int m = 3;
}
class B extends A {
	int m = 4;
}
public class Study_001 {
	public static void main(String[] args) {
		//객체 생성
		A aa = new A();
		B bb = new B();
		A ab = new B();
		
		//인스턴스 필드
		System.out.println(aa.m);
		System.out.println(bb.m);
		System.out.println(ab.m);
	}
}
3
4
3

 

2.2 정적 필드의 중복

정적 필드의 저장 공간은 정적 영역의 클래스 내부에 만들어지고, 모든 객체가 공유한다.

상속할 때 정적 필드명을 중복해 정의해도, 저장 공간이 분리돼 있으므로 오버라이딩은 발생하지 않는다.

 

  2.2.1 예제

package java_study_001;

class A {
	static int m = 3;
}
class B extends A {
	static int m = 4;
}
public class Study_001 {
	public static void main(String[] args) {
		//클래스명으로 바로 접근
		System.out.println(A.m);
		System.out.println(B.m);
		System.out.println();
		
		//객체 생성
		A aa = new A();
		B bb = new B();
		A ab = new B();
		
		//생성한 객체로 정적 필드 호출
		System.out.println(aa.m);
		System.out.println(bb.m);
		System.out.println(ab.m);
	}
}
3
4

3
4
3

 

2.3 정적 메서드의 중복

인스턴스 메서드가 오버라이딩됐던 이유는 동일한 공간에 동일한 이름의 메서드를 저장했기 때문이다.

하지만 정적 메서드는 정적 필드와 마찬가지로 각자의 클래스 내부에 존재한다.

즉, 다른 공간에 저장되는 것이다.

부모 클래스의 정적 메서드는 부모 클래스의 내부, 자식 클래스의 정적 메서드는 자식 클래스의 내부에 저장된다.

아무리 같은 이름이어도 오버라이딩되지 않는다.

 

  2.3.1 예제

package java_study_001;

class A {
	static void print() {
		System.out.println("A 클래스");
	}
}
class B extends A {
	static void print() {
		System.out.println("B 클래스");
	}
}
public class Study_001 {
	public static void main(String[] args) {
		//클래스명으로 바로 접근
		A.print();
		B.print();
		System.out.println();
		
		//객체 생성
		A aa = new A();
		B bb = new B();
		A ab = new B();
		
		//생성한 객체로 정적 필드 호출
		aa.print();
		bb.print();
		ab.print();
	}
}

 

2.4 인스턴스 멤버와 정적 멤버의 중복 정리

인스턴스 메서드는 객체가 어떤 생성자로 생성됐는지, 나머지는 어떤 타입으로 선언됐는지가 기준이 된다.

인스턴스 필드 인스턴스 메서드 정적 필드 정적 메서드
오버라이딩 X 오버라이딩 O
-> 메서드 오버라이딩
오버라이딩 X 오버라이딩 X

 

 

3. super 키워드와 super()

this는 자신의 객체, this()는 자신의 생성자를 의미한다면, super는 부모의 객체, super()는 부모의 생성자를 의미한다.

이는 모두 부모 클래스와 관련이 있으므로 상속 관계에서만 사용할 수 있다.

 

3.1 부모의 객체를 가리키는 super 키워드

super 키워드는 부모의 객체를 가리키는 것으로, 필드명의 중복 또는 메서드 오버라이딩으로 가려진 부모의 필드 또는 메서드를 호출하기 위해 사용한다.

만약 부모 키워드에 코드가 많고, 자식 클래스에는 그런 부모 클래스의 초기화 기능에 화면 출력 기능이 있는 코드 한 줄만 추가하고 싶다고 하자. 이때 super 키워드를 사용하면 자식 클래스의 메서드에서 부모 클래스의 메서드를 호출하고, 추가할 코드 한 줄만 적어주면 된다.

 

  3.1.1 예제 1 : 멤버 앞에 있는 참조 변수를 생략(this.)했을 때의 메서드 호출

package java_study_001;

class A {
	void abc() {
		System.out.println("A 클래스의 abc()");
	}
}
class B extends A {
	void abc() {
		System.out.println("B 클래스의 abc()");
	}
	void bcd() {
		abc(); // this.abc();
	}
}
public class Study_001 {
	public static void main(String[] args) {
		//객체 생성
		B bb = new B();
		
		//메서드 호출
		bb.bcd();
	}
}
B 클래스의 abc()

 

  3.1.2 예제 2 : 멤버 앞에 있는 super 키워드를 사용했을 때의 메서드 호출

package java_study_001;

class A {
	void abc() {
		System.out.println("A 클래스의 abc()");
	}
}
class B extends A {
	void abc() {
		System.out.println("B 클래스의 abc()");
	}
	void bcd() {
		super.abc(); // 부모 클래스 객체의 abc() 메서드 호출
	}
}
public class Study_001 {
	public static void main(String[] args) {
		//객체 생성
		B bb = new B();
		
		//메서드 호출
		bb.bcd();
	}
}
A 클래스의 abc()

 

3.2 부모 클래스의 생성자를 호출하는 super() 메서드

super()는 부모 클래스의 생성자를 호출한다.

this()와 마찬가지로 생성자의 내부에서만 사용할 수 있고, 반드시 첫 줄에 와야 한다.

여기서 중요한 사실은 모든 생성자의 첫 줄에는 반드시 this() 또는 super()가 있어야 한다는 것이다.

아무것도 써주지 않으면 컴파일러는 super()를 자동으로 삽입한다.

 

  3.2.1 예제 1 : super() 메서드의 기능 및 컴파일러에 따라 super() 자동 추가

package java_study_001;

class A {
	A() {
		System.out.println("A 생성자");
	}
}
class B extends A {
	B() {
		super(); //생략했을 때 컴파일러가 자동 추가(부모 클래스의 생성자 호출)
		System.out.println("B 생성자");
	}
}
class C {
	C(int a) {
		System.out.println("C 생성자");
	}
}
class D extends C {
	/* 컴파일러가 자동으로 추가해주는 내용
	D() {
		super();
	}
	*/
	D() {
		super(3);
	}
}
public class Study_001 {
	public static void main(String[] args) {
		//객체 생성
		A aa = new A();
		System.out.println();
		
		//메서드 호출
		B bb = new B();
	}
}
A 생성자

A 생성자
B 생성자

 

  3.2.2 예제 2 : this() 메서드와 super() 메서드의 혼용

package java_study_001;

class A {
	A() {
		this(3);
		System.out.println("A 생성자 1");
	}
	A(int a) {
		System.out.println("A 생성자 2");
	}
}
class B extends A {
	B() {
		this(3);
		System.out.println("B 생성자 1");
	}
	B(int a) {
		System.out.println("B 생성자 2");
	}
}

public class Study_001 {
	public static void main(String[] args) {
		// A객체 생성
		A aa1 = new A();
		System.out.println();
		A aa2 = new A(3);
		System.out.println();
		
		// B객체 생성
		// B의 두번째 생성자 첫 줄에는 this()도 super()도 없다.
		// 그러므로 컴파일러가 자동으로 super()을 추가해준다.
		B bb1 = new B();
		System.out.println();
		B bb2 = new B(3);
	}
}
A 생성자 2
A 생성자 1

A 생성자 2

A 생성자 2
A 생성자 1
B 생성자 2
B 생성자 1

A 생성자 2
A 생성자 1
B 생성자 2

 

 

4. 최상위 클래스 Object

자바의 모든 클래스는 Object 클래스를 상속받는다. 즉, Object 클래스는 자바의 최상위 클래스다.

컴파일러는 아무런 클래스로 상속하지 않으면 자동으로 extends Object를 삽입해 Object 클래스를 상속한다.

따라서 자바의 모든 클래스는 어떤 객체로 만들든지 Object 타입으로 선언할 수 있다.

 

4.1 Object 클래스의 주요 메서드

앞서 이야기한 것처럼 Object 클래스는 자바의 최상위 부모 클래스다. 이는 자바의 모든 클래스가 Object 클래스의 메서드를 포함하고 있다는 것을 의미한다.

 

  4.1.1 Object 클래스의 주요 메서드

반환 타입 메서드명 주요 내용
String toString() - Object 객체의 기본 정보 패키지.클래스명@해시코드
- 일반적으로 오버라이딩해서 사용
boolean equals(Object obj) - 입력매개변수 obj 객체와 stack 메모리값(번지) 비교
- 등가 비교 연산자 ==와 동일한 결과 
int hashCode() - 객체의 hashCode() 값 리턴. Hashtable, HashMap 등의 동등 비교에 사용
- 위칫값을 기반으로 생성된 고윳값
void wait()
wait(long timeout)
wait(long timeout, int nanos)
- 현재의 쓰레드를 일시정지(waiting/timed-waiting) 상태로 전환
- 보통 notify() 또는 notifyAll()로 일시정지 해제
- 동기화 블록에서만 사용 가능
void notify()
notifyAll()
- wait()를 이용해 일시정지 상태의 1개의 쓰레드(notify()) 또는 전체 쓰레드(notifyAll())의 일시정지 해제
- 동기화 블록에서만 사용가능

 

  4.1.2 toString() - 객체 정보를 문자열로 출력

  • Object 클래스의 toString() 메서드는 객체 정보를 문자열로 리턴하는 메서드다.
  • 객체 정보는 [ 패키지명.클래스명@해시코드 ]로 나타난다. 해시코드는 객체가 저장된 위치와 관련된 값이다.
  • 실제 객체의 정보를 표현하고자 할 때는 대부분 클래스명이나 숫자로 나열된 해시코드보다는 객체에 포함돼 있는 필드값을 출력한다.
package java_study_001;

class A {
	int a = 3;
	int b = 4;
}
class B {
	int a = 3;
	int b = 4;
	
	public String toString() {
		return "필드값(a, b) = " + a +" " + b;
	}
}

public class Study_001 {
	public static void main(String[] args) {
		//객체 생성
		A a = new A();
		B b = new B();
		
		//메서드 호출
		System.out.printf("%x\n", a.hashCode()); //hashcode를 16진수로 표현
		System.out.println(a.toString());
		System.out.println(b);
	}
}
1eb44e46
java_study_001.A@1eb44e46
필드값(a, b) = 3 4

 

  4.1.3 equlas(Object Obj) - 스택 메모리의 값 비교

  • equals(Object Obj)는 입력매개변수로 넘어온 객체와 자기 객체의 스택 메모리 변숫값을 비교해 그 결과를 true 또는 false로 리턴하는 메서드다.
  • 기본 자료형이 아닌 객체의 스택 메모리값을 비교하므로 실제 데이터의 값이 아닌 실제 데이터의 위치(번지)를 비교하는 것이다. 즉, 등가 비교연산(==)과 완벽하게 동일한 기능을 수행한다.
package java_study_001;

class A {
	String name;
	A(String name) {
		this.name = name;
	}
}
class B {
	String name;
	B(String name) {
		this.name = name;
	}
	@Override
	public boolean equals(Object obj) {
		if(this.name == ((B)obj).name) {
			return true;
		} else
			return false;
	}
}

public class Study_001 {
	public static void main(String[] args) {
		A a1 = new A("안녕");
		A a2 = new A("안녕");
		System.out.println(a1 == a2);
		System.out.println(a1.equals(a2));
		System.out.println();
		
		B b1 = new B("안녕");
		B b2 = new B("안녕");
		System.out.println(b1 == b2);
		System.out.println(b1.equals(b2));
	}
}
false
false

false
true

 

  4.1.4 hashCode() - 객체의 위치와 연관된 값

  • hashCode() 메서드는 객체의 위치와 관련된 값으로, 실제 위치를 나타내는 값은 아니다. 객체의 위칫값을 기준으로 생성된 고윳값 정도로 생각하는 것이 적절하다.
  • HashMap 자료 구조는 데이터를 (Key, Value)의 쌍으로 저장하며, Key 값은 중복되지 않는다. 따라서 Key 값이 서로 같은지를 확인해야 하는데, 이 과정은 2단계로 구성돼 있다.
    1. 두 객체의 hashCode() 값을 비교한다
    2. 값이 동일하면 equals() 메서드를 호출하고 이 값이 true면 같은 객체로 인식한다.
package java_study_001;

import java.util.HashMap;

class A {
	String name;
	A(String name) {
		this.name = name;
	}
	@Override
	public boolean equals(Object obj) {
		if(this.name == ((A)obj).name) {
			return true;
		} else
			return false;
	}
	@Override
	public String toString() {
		return name;
	}
}
class B {
	String name;
	B(String name) {
		this.name = name;
	}
	@Override
	public boolean equals(Object obj) {
		if(this.name == ((B)obj).name) {
			return true;
		} else
			return false;
	}
	@Override
	public int hashCode() {
		return name.hashCode();
	}
	@Override
	public String toString() {
		return name;
	}
}

public class Study_001 {
	public static void main(String[] args) {
		HashMap<Integer, String> hm1 = new HashMap<>();
		hm1.put(1, "데이터 1");
		hm1.put(1, "데이터 2");
		hm1.put(2, "데이터 3");
		System.out.println(hm1);
		
		HashMap<A, String> hm2 = new HashMap<>();
		hm2.put(new A("첫 번째"), "데이터 1");
		hm2.put(new A("첫 번째"), "데이터 2");
		hm2.put(new A("두 번째"), "데이터 3");
		System.out.println(hm2);
		
		HashMap<B, String> hm3 = new HashMap<>();
		hm3.put(new B("첫 번째"), "데이터 1");
		hm3.put(new B("첫 번째"), "데이터 2");
		hm3.put(new B("두 번째"), "데이터 3");
		System.out.println(hm3);
	}
}
{1=데이터 2, 2=데이터 3}
{첫 번째=데이터 1, 두 번째=데이터 3, 첫 번째=데이터 2}
{첫 번째=데이터 2, 두 번째=데이터 3}