[자바의 정석] 7. 객체지향 프로그래밍 2 - 상속과 오버라이딩

jyleever·2023년 12월 3일
0

자바의 정석

목록 보기
2/12
post-thumbnail

1. 상속

1.1 상속의 정의와 장점

  • 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것
  • extends 키워드를 이용해 상속을 구현한다.
- 조상 클래스
부모 클래스, 상위(super) 클래스, 기반(base) 클래스
- 자손 클래스
자식 클래스, 하위(sub) 클래스, 파생된(derived) 클래스
  • 자손 클래스는 조상 클래스의 모든 멤버를 상속받는다.
    Child 클래스는 Parent 클래스의 멤버들을 포함한다고 할 수 있다.
  • 조상 클래스가 변경되면 자손 클래스는 자동적으로 영향을 받게 되지만, 자손 클래스가 변경되는 것은 조상 클래스에 아무런 영향을 주지 않는다.
  • 자손 클래스는 조상 클래스의 모든 멤버를 상속 받으므로 항상 조상 클래스보다 같거나 많은 멤버를 갖는다.
  1. 생성자와 초기화 블럭은 상속되지 않는다. 멤버만 상속된다.
  2. 자손 클래스의 멤버 개수는 조상 클래스보다 항상 같거나 많다.

참고) 접근 제어자가 private 또는 default인 멤버들은 상속되지 않는다기보다 상속은 받지만 자손 클래스로부터 접근이 제한되는 것이다.

만약 한 조상 클래스를 상속 받고 있는 두 개의 다른 자손 클래스가 있을 때, 클래스 간의 관계에서 형제 관계 같은 것은 없으므로 공통적으로 추가 되어야 하는 멤버변수나 메서드가 있다면 공통 조상 클래스에 추가하는 것이 좋다.

자손 클래스의 인스턴스를 생성하면 조상 클래스의 멤버도 함께 생성되기 때문에 따로 조상 클래스의 인스턴스를 생성하지 않고도 조상 클래스의 멤버들을 사용할 수 있다.

자손 클래스의 인스턴스를 생성하면 조상 클래스의 멤버와 자손 클래스의 멤버가 합쳐진 하나의 인스턴스로 생성된다.

1.2 클래스 간의 관계 - 포함 관계

상속 이외에도 클래스를 재사용하는 또 다른 방법이 존재한다. 바로 클래스 간의 포함(Composite) 관계를 맺어주는 것이다.

클래스 간의 포함 관계를 맺어 주는 것은 한 클래스의 멤버 변수로 다른 클래스 타입의 참조 변수를 선언하는 것을 뜻한다.

class Circle {
  Point c = new Point();
  int r;
}
class Point {
  int x;
  int y;
}

하나의 거대한 클래스를 작성하는 것보다 단위 별로 여러 개의 클래스를 작성한 다음, 이 단위 클래스들을 포함 관계로 재사용하면 보다 간결하고 손쉽게 클래스를 작성할 수 있다.

1.3 클래스간의 관계 결정하기

~은 ~이다 (is-a) : 상속 관계
SuperCar은 Car이다
~은 ~을 가지고 있다 (has a) : 포함 관계
Circle은 Point를 가지고 있다`

+) 참조변수의 출력이나 덧셈 연산자를 이용한 참조 변수의 문자열의 결합에는 toString()이 자동적으로 호출되어 참조변수를 문자열로 대치한 후 처리한다.

1.4 단일 상속 (Single inheritance)

단일 상속이 하나의 조상 클래스만을 가질 수 있기 때문에 다중상속에 비해 불편한 점도 있지만 클래스 간의 관계가 보다 명확해지고 코드를 더욱 신뢰할 수 있게 만들어준다는 점에서 다중상속보다 유리하다.

  • 다중 상속을 허용하지 않으므로, 한 클래스(c)는 조상 클래스(A)를 상속 받고 다른 조상이 되는 클래스(B)를 포함 관계로 넣을 수 있다.
    그래서 포함관계의 클래스B의 메서드와 일치하는 선언부를 가진 메서드를 선언해 내용은 그 클래스의 것B을 호출하여 사용하도록 할 수 있다.
    외부적으로는 c클래스의 인스턴스를 사용하는 것처럼 보이지만 내부적으로 포함관계에 있는 조상 클래스B의 인스턴스를 생성해서 사용하는 것.
class TVCR extends Tv{
	VCR vcr = new VCR();
    
    void play() {
    	vcr.play();
    }
    void stop() {
    	vcr.stop();
    }
    ...
}

1.5 Object 클래스 - 모든 클래스의 조상

Object 클래스는 모든 클래스 상속 계층도의 최상위에 있는 조상클래스

  • 다른 클래스로부터 상속 받지 않는 모든 클래스들은 자동으로 Object 클래스로부터 상속받게 한다.
  • Object 클래스에는 toString(), equals()와 같은 모든 인스턴스가 가져야 할 기본적인 11개의 메서드가 정의되어 있다.

2. 오버라이딩

2.1 오버라이딩이란?

조상 클래스로부터 상속 받은 메서드의 내용을 변경하는 것

class Point {
	int x;
    int y;
    String getLocation() {
    	return "x: " + x + ", y :" + y;
    }
}
class Point3D extends Point{
	int z;
    @Override
    String getLocation(){
    	return "x: " + x + ", y : " + y + ", z : " + z;
    }
}

2.2 오버라이딩의 조건

오버라이딩은 메서드의 내용만을 새로 작성하는 것으로, 메서드의 선언부는 조상의 것과 완전히 일치해야 한다.

오버라이딩 성립 조건 - 메서드 선언부
자손 클래스에서 오버라이딩하는 메서드는 조상 클래스의 메서드와
1. 이름이 같아야 한다.
2. 매개변수가 같아야 한다.
3. 반환 타입이 같아야 한다.

선언부가 서로 일치해야 하지만, 접근 제어자와 예외는 제한된 조건 하에서만 다르게 변경할 수 있다.

오버라이딩 성립 조건 - 접근 제어자와 예외
1. 접근 제어자는 조상 클래스의 메서드보다 좁은 범위로 변경할 수 없다.
만일 조상 클래스에 정의된 메서드의 접근 제어자가 protected라면, 이를 오버라이딩하는 자손 클래스의 메서드는 접근 제어자가 protected나 public이어야 한다.
대부분 같은 범위의 접근 제어자를 사용한다.
(접근 제어자 접근 범위 : public - protected - (default) - private)
2. 조상 클래스의 메서드보다 많은 수의 예외를 선언할 수 없다.
아래는 Exception 수가 더 적어도 잘못된 예다. Exception은 모든 예외의 최고 조상이므로 가장 많은 개수의 예외를 던질 수 있기 때문

class Parent{
	void parentMethod() throws IOException, SQLException {
    	...
    }
}
class Child extends Parent {
	void parentMethod() throws IOException {
    	...
    }
}

조상 클래스의 메서드를 자손 클래스에서 오버라이딩할 떄
1. 접근 제어자를 조상 클래스의 메서드보다 좁은 범위로 변경할 수 없다.
2. 예외는 조상 클래스의 메서드보다 많이 선언할 수 없다.
3. 인스턴스 메서드를 static 메서드로 또는 그 반대로 변경할 수 없다.

+) 참고

  • 조상 클래스에 정의된 static 메서드를 자손 클래스에서 똑같은 이름의 static 메서드로 정의할 수 있다.
    하지만 이는 각 클래스에 별개의 static 메서드를 정의한 것일 뿐 오버라이딩이 아니다. 각 메서드는 클래스 이름으로 구별될 수 있으며, 호출할 때는 '참조변수.메서드이름()' 대신 클래스 이름.메서드 이름() 으로 하는 것이 바람직하다. static 멤버들은 자신들이 정의된 클래스에 묶여있다고 생각할 수 있다.

2.3 오버로딩 vs 오버라이딩

오버로딩 : 기존에 없는 새로운 메서드를 정의하는 것(new)
오버라이딩 : 상속 받은 메서드의 내용을 변경하는 것(modify, change)

2.4 super

자손 클래스에서 조상 클래스로부터 상속 받은 멤버를 참조하는 데 사용되는 참조 변수
멤버 변수와 지역 변수의 이름이 같을 때 this를 붙여서 구별했듯이, 상속 받은 멤버와 자신의 멤버와 이름이 같을 때는 super을 붙여서 구별할 수 있다.

  • 조상클래스로부터 상속받은 멤버도 자손 클래스 자신의 멤버이므로 super 대신 this를 사용할 수 있다.
    그래도 조상 클래스의 멤버와 자손 클래스의 멤버가 중복 정의되어 서로 구별해야 하는 경우에만 super을 사용하는 것이 좋다

  • 조상의 멤버와 자신의 멤버를 구별하는데 사용된다는 점을 제외하고는 super와 this는 근본적으로 동일하다.
    모든 인스턴스 메서드에는 자신이 속한 인스턴스의 주소가 지역변수로 저장되는데, 이것이 참조 변수인 this와 super의 값이 된다.

  • static 메서드(클래스 메서드)는 인스턴스와 관련이 없다. 그래서 this와 마찬가지로 super 역시 static 메서드에서는 사용할 수 없고, 인스턴스 메서드에서만 사용할 수 있다.

class SuperTest2{
	public static void main(String args[]){
    	Child c = new Child();
        c.method();
    }
}
class Parent{
	int x = 10;
}
class Child extends Parent{
	int x = 20;
    void method(){
    	System.out.println(x): // 20
        System.out.println(this.x); // 20
        System.out.println(super); // 10
    }
}

조상 클래스에 선언된 멤버 변수와 같은 이름의 멤버 변수를 자손 클래스에서 중복해서 정의하는 것이 가능하며 참조변수 super를 이용해 서로 구별할 수 있다.

class Point{
	int x;
    int y;
    String getLocation() {
    	return "x: "+ x + ", y" + y;
    }
}

class Point3D extends Point {
	int z;
    String getLocation() {
    	// return "x: "+ x + ", y" + y + ", z : " + z;
        return super.getLocation() + ", z : " + z; // 조상 메서드 호출
    }
}

조상 클래스의 메서드 내용에 추가적으로 작업을 덧붙이는 경우라면 이처럼 super을 사용해 조상 클래스의 메서드를 포함시키는 것이 좋다.

2.5 super() - 조상 클래스의 생성자

  • this()와 마찬가지로 super() 역시 생성자
    this()는 같은 클래스의 다른 생성자를 호출하는 데 사용되지만, super()는 조상 클래스의 생성자를 호출하는 데 사용된다.

  • 자손 클래스의 인스턴스를 생성하면, 자손의 멤버와 조상의 멤버가 모두 합쳐진 하나의 인스턴스가 생성된다.
    이때 조상 클래스 멤버의 초기화 작업이 수행되어야 하기 때문에 자손 클래스의 생성자에서 조상 클래스의 생성자가 호출되어야 한다.

  • 자손 클래스의 멤버가 조상 클래스의 멤버를 사용할 수도 있으므로 조상의 멤버들이 먼저 초기화되어야 한다. 따라서 생성자 첫 줄에서 조상 클래스의 생성자를 호출해야 한다. 조상 클래스 생성자 호출은 상속 관계를 거슬러 올라가면서 계속 반복된다.

Object 클래스를 제외한 모든 클래스의 생성자 첫 줄에 생성자.this() 또는 super()를 호출해야 한다. 그렇지 않으면 컴파일러가 자동으로 super();을 생성자의 첫 줄에 삽입한다.

인스턴스를 생성할 때는 클래스를 선택하는 것만큼 생성자를 선택하는 것도 중요하다.

1. 클래스 - 어떤 클래스의 인스턴스를 생성할 것인가?
2. 생성자 - 선택한 클래스의 어떤 생성자를 이용해 인스턴스를 생성할 것인가?

예제

class PointTest{
	public static void main(String args[]){
    	Point3D p3 = new Point3D(1,2,3);
    }
}
class Point{
	int x, y;
    Point(int x, int y){
    	this.x = x;
        this.y = y;
    }
}

class Point3D extends Point{
	// 생성자 첫 줄에서 다른 생성자를 호출하지 않으면
    // 컴파일러는 여기에 super()을 삽입한다.
    // 여기서 super()는 조상 클래스 Point 클래스의 기본 생성자 Point()
    this.x = x;
    this.y = y;
    this.z = z;
}

이 예제를 컴파일하면 컴파일 에러가 발생한다. Point3D클래스의 생성자에서 조상 클래스의 생성자인 Point()를 찾을 수 없다는 내용이다.

문제 원인은 다음과 같다.
1. Point3D 클래스의 생성자의 첫 줄이 생성자를 호출하는 문장이 아니다.
2. 따라서 컴파일러는 자동적으로 super();을 생성자의 첫 줄에 넣어준다.
3. 그래서 Point3D 클래스의 인스턴스를 생성하면, 생성자 Point3D(int x, int y, intz)가 호출된다.
4. 이때 조상 클래스인 Point 클래스에서의 기본 생성자 Point()를 호출하나 실제로는 Point 클래스에 생성자 Point()가 정의되어있지 않아 찾을 수 없다.
5. 컴파일 에러가 발생한다.

문제 해결 방안은 다음 두 가지다. 둘 중 한 가지로 해결할 수 있다.
1. Point 클래스에 생성자 Point()를 추가해준다.
2. 생성자 Point3D(int x, int y, int z)의 첫 줄에서 Point(int x, int y)를 호출하도록 변경한다.

Point3D(int x, int y, int z){
	super(x, y); // 조상 클래스의 생성자 Point(int x, int y)를 호출해준다.
    this.x= y;
}

✔ 정리

  • this()는 같은 클래스의 다른 생성자를 호출하는 데 사용되지만, super()는 조상 클래스의 생성자를 호출하는 데 사용된다.
  • 자손 클래스는 생성자 첫 줄에서 조상 클래스의 생성자를 호출해야 한다.
  • 조상 클래스의 생성자가 없으면 컴파일러가 자동으로 super();을 첫 줄에서 추가한다.
  • 생성자가 정의되어있는 클래스에는 컴파일러가 기본 생성자를 자동으로 추가하지 않는다.
  • 조상 클래스의 멤버변수는 조상의 생성자에 의해 초기화되도록 해야 한다.

참고)
Point3D p3 = new Point3D();와 같이 인스턴스를 생성하면, 아래와 같은 순서로 생성자가 호출된다.

Point3D() -> Point3D(int x, int y, int z) -> Point(int x, int y) -> Object()

0개의 댓글