학습할 것

  • 자바 상속의 특징
  • super 키워드
  • 메소드 오버라이딩
  • 다이나믹 메소드 디스패치 (Dynamic Method Dispatch)
  • 추상 클래스
  • final 키워드
  • Object 클래스

상속


상속의 개념

  • 상속이란 기존 클래스의 변수와 메소드를 물려 받아 새로운 클래스(더 나은, 더 구체적인 클래스)를 구성하는 것을 의미합니다.
  • 이러한 상속은 캡슐화, 추상화, 다형성과 더불어 객체지향프로그래밍을 구성하는 특징 중 하나입니다.
  • 쉽게 이해해 보자면 현실 세계에서 부모의 생물학적 특징을 자식이 물려받은 유전과 비슷하다고 생각할 수 있습니다.

상속의 필요성

상속의 장점은 다음과 같습니다.

  • 기존 클래스의 변수와 코드를 재사용할 수 있어 개발 시간이 단축됩니다.
  • 먼저 작성된 프로그램 재사용하기 때문에 신뢰성 있는 프로그램을 개발할 수 있습니다.
  • 클래스 간 계층적 분류 및 관리가 가능하여 유지보수가 용이합니다.

클래스 상속

상속 선언

  • 부모 클래스를 슈퍼클래스(super class), 상속받는 자식 클래스를 서브클래스(sub class)라고 부릅니다.
  • 상속을 선언할 때는 extends 키워드를 사용합니다.
public class Phone { 
	public void call() {
    	System.out.println("전화를 겁니다...");
    }
    public void send() {
    	System.out.println("전화를 받습니다...");
    }
public class smartPhone extends Phone { 
	public void wifi() {
    	System.out.println("와이파이 실행...");
    }
  • 스마트폰 클래스는 전화기 클래스의 call(), send() 기능을 물려받으므로 스마트폰 클래스에서 메소드를 다시 반복하여 작성할 필요 없습니다.
  • 스마트폰의 추가 기능인 wifi() 메소드를 작성하면 됩니다.

서브 클래스 객체

public class Ex01 {
	public static void main(String[] args) {
    	Phone p1 = new Phone(); 👈 객체 생성
        p1.call();
        p1.send();
        
        smartPhone p2 = new smartPhone();
        p2.call();
        p2.send();
        p2.wifi();
  • 생성된 객체는 p1과 p2입니다.
  • 객체 p1은 Phone클래스의 멤버만 가지고, p2는 Phone 클래스와 smartPhone 클래스의 멤버와 모두 가집니다.

상속의 특징

상속의 특징은 다음과 같습니다.

  • 자바에서는 클래스의 다중상속을 지원하지 않습니다.
    👆 클래스를 여러개 상속받는 다중상속을 지원하지 않으므로 extends 다음에는 클래스 이름 하나만 지정할 수 있습니다.
  • 자바에서는 상속의 횟수에 제한을 두지 않습니다.
  • 자바에서 계층구조의 최상위에 java.lang.Object 클래스가 있습니다.
    👆 자바에서 모든 클래스는 Object클래스를 자동으로 상속받도록 되어있습니다.
    👆 toString(), equals()와 같은 메소드를 바로 사용할 수 있습니다.

super 키워드


super 키워드는 상속 관계에서 부모 클래스의 메소드나 필드를 명시적으로 참조하기 위하여 사용됩니다. 만약 부모 클래스의 메소드나 필드를 오버라이드한 경우에 super를 사용하면 부모 클래스의 메소드나 필드를 호출할 수 있습니다.

보통 메소드를 오버라이드할 때, 부모 클래스의 메소드를 완전히 대치하는 경우보다 내용을 추가하는 경우가 많습니다. 이런 경우에는 spuer 키워드를 이용하여 super 클래스의 메소드를 호출해 준 후에 자신이 필요한 부분을 추가해주는 것이 좋습니다.

class Parent {
	public void print() {
    	System.out.println("부모 클래스의 print() 메소드");
    }
}
    
public class Child extends Parent {
	public void print() {
    	super.print();
        System.out.println("자식 클래스의 print() 메소드");
    }
    
    public static void main(String[] args) {
    	Child obj = new Child();
        obj.print();
    }
}

실행결과

부모 클래스의 print() 메소드
자식 클래스의 print() 메소드

super.메소드명() 형태로 부모 클래스의 메소드에 접근할 수 있음을 알 수 있는데, super() 형태로 부모 클래스의 생성자에 접근할 수도 있습니다. 다만 부모 클래스의 생성자에 매개 변수가 있는 경우라면 super(매개변수1, 매개변수2)와 같이 동일한 형태가 갖추어지지 않을 경우 오류가 발생합니다.

메소드 오버라이딩


오버라이딩이란?

부모 클래스로부터 상속받은 메소드를 자식 클래스에서 재정의하여 사용하는 것입니다.

Parent.java

public class Parent {
	public void method() {
    	System.out.println("Parent");
    }
}

Child.java

public class Child extends Parent {
	public void method() { 👈 Parent의 method 메소드를 오버라이딩 했음
    	System.out.println("Child");
    }
}

Main.java

public class Main {
  public static void main(String[] args) {
      Parent parent = new Parent();
      Child child = new Child();
      parent.method(); 👈 오버라이딩 한 Child의 method 메소드를 호출함
      child.method(); 👈 오버라이딩 한 Child의 method 메소드를 호출함
  }
}

실행결과

Parent
Child

오버라이딩 조건

  • 메소드의 이름, 리턴 타입, 매개변수의 갯수, 자료형과 순서를 동일하게 하여 자식 클래스에서 작성해야 합니다.
  • 접근 제어자는 주로 부모클래스와 동일하게 사용하지만 접근 범위를 넓게 지정할 수는 있습니다.(예 : default → public)
  • 인스턴스 변수와 클래스 변수, 클래스 메소드는 오버라이딩 대상이 아닙니다.

어노테이션 @Override

오버라이딩을 하다가 @Override를 생략해도 메소드가 정상적으로 실행된다는 것을 확인할 수 있습니다.
그럼 @Override를 왜 쓰는걸까요?

  1. 안전핀 역할
    예를 들어, 상속하는 클래스 메소드의 시그니처가 바뀌었을 때, 전에 오버라이드한 메소드가 업데이트 이후에는 추가적인 메소드로 인식이 되며, 컴파일 오류가 발생하지 않습니다.
    이러한 상황을 방지하기 위해 @Override를 작성함으로써 의도적으로 컴파일 오류를 일으켜 작동방식이 바뀌는 것을 미리 대비할 수 있습니다.

  2. 가독성
    @Override를 표시함으로써 코드 리딩시에 이 메소드가 오버라이딩하였음을 비교적 쉽게 파악할 수 있습니다.

메소드 시그니처?
메소드의 이름과 파라미터(매개변수)를 말합니다.

오버로딩과 오버라이딩

  • 오버로딩 : 새로운 메소드를 정의하는 것
  • 오버라이딩 : 상속받은 기존의 메소드를 재정의하는 것

Parent.java

public class Parent {
	public void print() {
        System.out.println("부모 클래스의 print() 메소드");
    }
}

Child.java

public class Child extends Parent{
	public void print() {
        System.out.println("자식 클래스의 print() 메소드");
    }
	public void print(String str) {
        System.out.println(str);
    }
}

Main.java

public class Main {
	public static void main(String[] args) {
	    Child child = new Child();
	    child.print();
	    child.print("오버로딩된 print() 메소드");
	}
}

실행결과

자식 클래스의 print() 메소드
오버로딩된 print() 메소드

다이나믹 메소드 디스패치


메소드 디스패치란?

어떤 메소드를 호출할지 결정하여 실제로 실행시키는 과정입니다.
자바는 런타임시 객체를 생성하고 컴파일 시에는 생성할 객체 타입에 대한 정보만 보유합니다.
이 과정은 static(정적)과 dynamic(동적)이 있습니다.

컴파일타임(Compile time)이란?
개발자가 작성한 소스코드를 기계어 코드로 변환되어 실행 가능한 프로그램이 되는 과정입니다.
런타임(Run time)이란?
컴파일 과정을 마친 응용 프로그램이 사용자에 의해서 실행되어 지는 때(time)를 의미합니다.

정적 디스패치

컴파일 시점에서 컴파일러가 특정 메소드를 호출할 것이라고 명확하게 알고 있는 경우입니다.

Parent.java

public class Parent {
	public void print() {
        System.out.println("Hello");
    }
	public void print(String greeting) {
		System.out.println(greeting);
	}
}

Main.java

public class Main {
	public static void main(String[] args) {
		Parent parent = new Parent();
		parent.print();
		parent.print("hihi");
	}
}

실행결과

Hello
hihi

런타임이 아닌 컴파일타임에 컴파일러 사용자 바이트코드 모두 어떤 메소드가 실행될지 알고 있습니다.

동적 디스패치

정적 디스패치와 반대로 컴파일러가 어떤 메소드를 호출하는지 모르는 경우입니다. 동적 디스패치는 호출할 메소드를 런타임 시점에서 결정합니다.

Parent.java

public class Parent {
	public void print() {
        System.out.println("부모");
    }
}

Child.java

public class Child extends Parent{
	public void print() {
		System.out.println("자식");
    }
}

Main.java

public class Main {
	public static void main(String[] args) {
		Parent child = new Child();
		child.print();
	}
}

실행결과

자식

객체의 선언 타입은 Parent이고 생성 타입은 Child입니다. 컴파일 시간에는 print() 메소드가 Parent.print() 일지 Child.print() 일지 판단하지 못합니다.
런타임 시점에서 어떤 객체가 생성되어 할당되었는지 판단하게 됩니다.

추상 클래스


추상화란?

공통된 행동, 필드를 묶어 하나의 클래스를 만드는 것을 의미합니다.
예를 들어 강아지, 고양이 등의 "펫"은 먹기/걷기 등의 행동을 하기 때문에 "펫"이라는 추상 클래스를 만들 수 있습니다.

추상클래스란?

하나 이상의 추상 메소드를 포함한 클래스를 추상 클래스(abstract class)라고 합니다.
단 하나 이상의 추상 메소드만 포함하면 되며 생성자, 일반 메소드도 포함 가능합니다.
이때 추상 메소드란, 함수 선언만 되어있고 구현부가 없는 아래와 같은 메소드를 말합니다.

public abstract class 클래스명();

추상 클래스의 일부 다형성 보장

추상 클래스는 "다형성"을 보장하기 위해 나타난 개념인데,
"자식 클래스에서 반드시 재정의가 되어야 된다"는 점에서 다형성이 보장됩니다.
부모 클래스에서 추상 메소드를 선언하면, 자식 클래스는 부모의 추상적 메소드를 상속받아 메소드를 구현해 그 기능들을 구현 가능한데, 이 때 부모가 가진 추상 메소드들을 자식 클래스에서 반드시 재정의(오버라이딩)해야 한다. 즉 부모가 자식에게 명령을 내렸을 때 자식 클래스가 반드시 동작되도록 재정의한다는 점에서 다형성이 보장됩니다.

추상 클래스 선언 방법

추상 메소드 및 추상 클래스는 "abstract"키워드로 선언하며, 다음과 같이 선언합니다.

abstract class 클래스 이름 {
	abstract 리턴타입 메소드이름();
}

또한 추상 메소드의 경우
자식 클래스에서 구현이 반드시 이뤄줘야 하기 때문에 아래와 같이 private 접근제어자는 사용할 수 없습니다.

abstract private void walk() 👈 불가

[예시] 추상클래스 Animal 예시

abstract class Animal {
	abstract public void walk();
	abstract public void eat();
	protected void run() {
		System.out.println("run run");
	}
}

접근제한자 설명
public : 외부 클래스에서 자유롭게 사용 가능
protected : 같은 패키지 또는 자식 클래스에서 사용 가능
private : 외부에서 사용 불가능

추상클래스 상속

추상 클래스도 상속이 가능하다. 추상 클래스의 상속 관계에는 어떠한 "구현 의무"가 있습니다.
부모 클래스를 상속 받은 자식 클래스는 반드시 부모의 추상 함수(abstract class)를 구현해야 한다는 점입니다.
부모 추상클래스는 말 그대로 "추상"이므로 객체를 생성할 수 없기 때문에, 자식 클래스에서 그 함수들을 구현 및 구체화해야 합니다.

Dog.java

class Dog extends Animal {
	public void walk() {
		System.out.println("Dog walk");
	}
	public void eat() {
		System.out.println("Dog eat");
	}
}

Cat.java

class Cat extends Animal {
	public void walk() {
		System.out.println("Cat walk");
	}
	public void eat() {
		System.out.println("Cat eat");
	}
} 

[적용예시]

추상 클래스는 객체를 생성할 수 없기에 아래와 같이 같이 자식 객체만 선언 가능합니다.
또한 자식 객체는 부모 클래스의 멤버 함수(protected)를 호출하여 사용할 수 있습니다.

AnimalExample.java

public class AnimalExample {
	public static void main(String[] args) {
		Dog d = new Dog();
		d.eat();
		d.walk();
		d.run();
		
		Cat c = new Cat();
		c.eat();
		c.walk();
	}
}

실행결과

Dog eat
Dog walk
run run
Cat eat
Cat walk

final 키워드


final 변수

  • 상수라고 불립니다.
  • 변수를 선언과 동시에 초기화하며 이후에 값을 수정할 수 없습니다.
  • 오직 get만 가능합니다.
public class Fruit {
	public static void main(String[] args) {
    	final int count = 10; 👈 선언 및 초기화
        count = 15; 👈 수정
    }
}

count 변수를 선언할 때 final 키워드를 추가해 줌으로써 상수임을 나타내고 있습니다.
그리고 선언과 동시에 초기화를 진행하여 10을 대입해 주었습니다.

이후 값을 15로 수정하려고 하면 다음과 같은 에러가 발생합니다.
count 변수에 값을 넣을 수 없다는 뜻입니다.

일반적으로 final 변수는 프로그램 전체에 걸쳐 사용되는 경우가 많아서 위처럼 특정 메소드 내부에서 선언하기 보다는 클래스에 static 키워드와 함께 정의되어 사용합니다.

public class Fruit {
	static final int count = 10;
    static final double PI = 3.14;
    static final String FILE_NAME = "Config";
    
	public static void main(String[] args) {}
}

모든 변수 타입(int, double, String 등)에 적용할 수 있으며
폴더/파일 이름, DB컬럼명, 사이즈 등의 정보를 표현하는데 유용합니다.

final 메소드

  • 오버라이딩이 불가능합니다.
  • 상속 받은 그대로 사용해야 됩니다.

Fruit.java

public class Fruit {
	public String name;

	public final String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}
}

Banana.java

public class Banana extends Fruit {
	public String getName() {
		return name;
	}
	
	@Override
	public void setName(String name) {
		this.name = "Fruit Name : " + name;
	}
	
	public static void main(String[] args) {}
}

Fruit 클래스에는 name에 대한 set/get 메소드가 존재합니다.
여기서 집중해야 할 부분은 getName() 메소드 앞에 위치한 final 키워드입니다.

Banana 클래스는 Fruit 클래스를 상속받아
setName() 메소드를 원하는 형태로 오버라딩 했습니다.

그런데 final 메소드인 getName()도 오버라이딩이 가능할까요?
이 때 다음과 같은 에러가 발생한다. 즉, 오버라이딩이 불가능하다는 것입니다.

final 클래스

  • 상속이 불가능합니다.
  • subclass를 만들 수 없습니다.

Fruit.java

public final class Fruit {
	public String name;

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}
}

Banana.java

public class Banana extends Fruit {
	public static void main(String[] args) {}
}

이번에는 Fruit 클래스 앞에 final 키워드가 위치해 있습니다.
앞에서와 같이 Banana 클래스에서 Fruit 클래스를 상속하려고 합니다.

예상처럼 Fruit 클래스는 final 클래스이기 때문에 상속이 불가능하다는 에러가 나타납니다.

final 메소드와 클래스는 주로 라이브러리 형태의 프로젝트를 작성할 때 사용합니다.

Object 클래스


Object 클래스

Object 클래스란 자바에서 모든 클래스의 부모 클래스입니다.

따라서 여러 다른 타입의 객체들을 업 캐스팅을 통해 하나의 매개변수 파라미터에 넣어주거나, 아니면 기본 제공하는 메소드들을 사용하거나 오버라이딩해서 사용합니다.

가장 많이 쓰이는 메소드로는 toString(), equals(), hashCode() 등이 있습니다.

캐스팅?
타입을 변환하는 것
업캐스팅?
자식 클래스의 객체가 부모 클래스 타입으로 형변환 되는 것
다운캐스팅?
업캐스팅된 것을 다시 원상태로 돌리는 것

자주 쓰이는 메소드

1. toString()

Object 클래스의 toString() 메소드는 객체의 문자 정보를 리턴합니다.

객체 문자 정보 : 객체를 문자열로 표현한 값
기본적으로 Object 클래스의 toString() 메소드는 "클래스명@16진수해시코드"로 구성된 문자 정보를 리턴

public class EqualsTest {
	public static void main(String[] args) {
		Object data = new Object();
		System.out.println(data.toString());
	}
}

실행결과

java.lang.Object@1ed6993a

1-1. toString() 활용

toString() 메소드의 리턴값은 값어치가 없는 정보이므로
Object의 하위 클래스는 toString() 메소드를 오버라이딩하여 유익한 정보를 리턴합니다.

주로 toString() 메소드의 오버라이딩 목적은 "객체의 정보를 문자열 형태로 표현"하는 것에 있습니다.

예) java.util 패키지의 Data 클래스는 toString() 메소드를 오버라이딩하여 현재 시스템의 날짜와 시간 정보를 리턴합니다.

public class EqualsTest {
	public static void main(String[] args) {
		Object data1 = new Object();
		Date data2 = new Date();
		System.out.println(data1.toString());
		System.out.println(data2.toString());
	}
}

실행결과

java.lang.Object@7ab2bfe1
Thu Aug 25 17:26:34 KST 2022

매개값이 기본 타입(byte, short, int, long, double, boolean)일 경우 해당 값을 그대로 출력합니다.

만약 매개값으로 객체를 주면,
객체의 toString() 메소드를 호출해서 리턴값을 받아서 출력하도록 되어 있습니다.

2. equals()

Object 클래스의 equals() 메소드는 두 객체의 값이 같은지 다른지에 대한 boolean 값을 반환해주는 메소드입니다.

public class EqualsTest {
	public static void main(String[] args) {
		String data1 = new String("ABC");
		String data2 = new String("ABC");
		
		System.out.println(data1 == data2);
		System.out.println(data1.equals(data2));
	}
}

실행결과

false
true

다음과 같이 물리적으로 메모리 주소가 같지 않더라도, 값이 동일하면 true를 반환해 주는 것을 확인할 수 있습니다.

Object 클래스의 equals() 내부 코드는 다음과 같습니다.

public boolean equals(Object anObject) {
  if (this == anObject) {
      return true;
  }
  if (anObject instanceof String) {
      String aString = (String)anObject;
      if (coder() == aString.coder()) {
          return isLatin1() ? StringLatin1.equals(value, aString.value)
                            : StringUTF16.equals(value, aString.value);
      }
  }
  return false;
}
  • 만약 전달받은 객체의 메모리 주소가 같다면 true를 반환
  • 그렇지 않다면 값을 비교한 뒤 true/false 반환

2-1. equals() 활용

Object 클래스의 equals() 메소드를 오버라이딩하는 주목적은

"물리적으로 다른 메모리 주소를 갖는 객체더라도, 논리적으로 동일한지 여부를 반환" 하기 위해서라고 할 수 있습니다.

위 목적에 맞게 작성한 예시코드를 통해 어떻게 equals()메소드가 오버라이딩 되는지 확인해봅시다.

public class Drink {
	private String name;
	private String kind;
	
	public Drink(String name, String kind) {
		this.name = name;
		this.kind = kind;
	}
	
	public String getKind() {
		return kind;
	}
	
	@Override
	public boolean equals(Object obj) {
		if(obj instanceof Drink) {
			return this.getKind() == ((Drink)obj).getKind();
		} else {
			return false;
		}
	}
	
	public static void main(String[] args) {
		Drink drink1 = new Drink("삼다수", "물");
		Drink drink2 = new Drink("오아시스", "물");
		
		System.out.println(drink1.equals(drink2));
	}
}

실행결과

true

"마실 것"을 저장하는 클래스인 Drink를 정의했습니다.

"이름(name)이 달라도 종류(kind)가 같으면 같다" 라는 논리적 동일성을 재정의하기 위해 equals() 메소드를 오버라이딩 했습니다.

3. hashCode()

Object 클래스의 hashCode() 메소드는 객체 해시코드를 반환해주는 메소드입니다.

객체 해시코드 : 객체를 식별할 하나의 정수값

Object 클래스의 hashCode()는 객체의 메모리 주소를 이용해서 해시코드를 만들어 리턴하기 때문에 객체마다 다른 값을 가지고 있습니다.

내부적으로 JVM인스턴스를 생성할 때 메모리 주소를 10진수로 변환하여 해시코드를 부여합니다.
따라서 실제 메모리 주소와 별개의 값이므로 실제 메모리 주소를 알고 싶을 때는 System 클래스의 identityHashCode()로 확인할 수 있습니다.

Drink drink1 = new Drink("삼다수", "물");
Drink drink2 = new Drink("오아시스", "물");

System.out.println(drink1.hashCode());
System.out.println(drink2.hashCode());

실행결과

1057941451
1975358023

3-1. hashCode() 활용

Object 클래스의 hashCode() 메소드는 "물리적으로 다른 메모리 주소를 갖는 두 객체에 동일성을 부여한다" 라는 목적으로 오버라이딩 할 수 있습니다.

위 목적에 맞게 작성한 예시코드를 통해 어떻게 hashCode() 메소드가 오버라이딩 되는지 확인해봅시다.

public class Drink {
	private String name;
	private String kind;
	
	public Drink(String name, String kind) {
		this.name = name;
		this.kind = kind;
	}
	
	public String getKind() {
		return kind;
	}
	
	@Override
	public boolean equals(Object obj) {
		if(obj instanceof Drink) {
			return this.getKind() == ((Drink)obj).getKind();
		} else {
			return false;
		}
	}
	
	@Override
	public int hashCode() {
		return getKind().hashCode();
	}
	
	public static void main(String[] args) {
		Drink drink1 = new Drink("삼다수", "물");
		Drink drink2 = new Drink("오아시스", "물");
		
		System.out.println(drink1.hashCode());
		System.out.println(drink2.hashCode());
	}
}

실행결과

47932
47932

equals()와 마찬가지로 "이름(name)이 달라도 종류(kind) 같으면 같다"라는 논리적 동일성을 재정의하기 위해 hashCode() 메소드를 오버라이딩 했습니다.

String data1 = "ABC"; 와 String data3 = new String("ABC");의 차이

String 자료형으로 그 자체로 클래스 타입이기 때문에 다른 원시 자료형과는 달리 선언 형태에 따라 실제 주소값을 할당이 달라지게 됩니다. 이는 값에 중점을 둘 것인가, 객체에 중점을 둠에 따라 달라지게 됩니다.

String data1 = "ABC"; 값에 중점을 둔 형태입니다. 따라서
String data2 = "ABC"; 라 선언해도 data1, data2의 주소값은 같습니다.
이는 data1을 선언할 때 "ABC" 메모리 중 text의 상수 영역에 선언한 뒤

이 주소값을 같은 값을 갖는 data2에 부여하기 때문입니다.

String data3 = new String("ABC"); 객체에 중점을 둔 형태입니다. 따라서
String data4 = new String("ABC"); 따라서 선언하면 data3, data2의 주소 값은 다르게 나옵니다.

출처

https://danmilife.tistory.com/21
https://cigiko.cafe24.com/java-super-%ED%82%A4%EC%9B%8C%EB%93%9C/
https://blog.naver.com/heartflow89/220961515893
https://team00csduck.tistory.com/11
https://velog.io/@rabbit/다이나믹-메소드-디스패치
https://life-with-coding.tistory.com/487
https://library1008.tistory.com/1
https://blog.naver.com/line9988/222844780256
https://kephilab.tistory.com/92
https://velog.io/@heoseungyeon/Object객체-탐구하기-toString-equals-hashCode

0개의 댓글