상속
객체지향의 본격적인 특징이 나오는 파트이다. 가장 중요하기 때문에 확실하게 이해하고, 확실하게 넘어가도록 해보자. 객체지향언어의 특징에는 총 4가지가 있다. 캡슐화, 상속, 다형성, 추상화 이번 파트에서는 상속에 대해서 자세히 알아보자.
상속이란 기존의 클래스로 새로운 클래스를 작성하는 것이다. → 객체지향의 특징인 코드의 재사용을 가능하게 해준다. 또 다르게 말하자면 부모와 자식의 관계를 맺어주는 것인데 상속을 선언하는 법은 다음과 같다.
class 자식클래스 extends 부모클래스 {
}
위 처럼 작성하면 자식클래스는 부모클래스에 있는 멤버들을 상속받게 된다. 즉, 부모에 있는 멤버들을 자식도 쓸 수 있다는 것이다. 단, 여기서 주의해야 할 점은 생성자와 초기화 블럭은 상속이 안되니 주의해야 한다.
여기서 궁금한 부분이 생겼다. 생성자와 초기화 블럭은 상속이 안된다고 했는데, 그럼 명시적 초기화는 상속이 되는 걸까? 하고 다음과 같이 테스트를 해봤다.
public class main
{
public static void main(String[] args)
{
SuperCar.test(); // 1. 클래스 멤버도 똑같이 상속이 되고, 자식이 사용할 수 있는가?
SuperCar car = new SuperCar();
System.out.println(car.door); // 2. 부모에서 명시적 초기화가 된 멤버 변수는 그대로 자식에게 물려지는가?
}
}
class Car {
String color;
String gearType;
int door = 4;
Car(){
this("red", "auto", 4);
}
Car(String color, String gearType, int door){
this.color = color;
this.gearType = gearType;
this.door = door;
}
static void test() {
System.out.println("zz");
}
}
class SuperCar extends Car {
boolean caption;
}
테스트를 진행하면서 한 가지 더 의문인것은 애플리케이션이 실행되고 메모리가 올라갈 때 부모 영역에 있는 클래스 멤버는 메소드 영역에 저장이 되는데, 이 메소드 영역에 있는 클래스 멤버를 자손이 그대로 접근할 수 있는가? 에 대한 궁금증이 하나 더 생겼는데, 정상적으로 가지고 오는 것을 볼 수 있었다.
두 번 째로 명시적 초기화가 된 멤버 변수 또한 초기화가 된 상태로 자손에게 물려지는 것을 볼 수 있었다.
따라서, 여기서 중요한 포인트가 있다. 자식 클래스는 부모 클래스의 멤버를 그대로 물려받기 때문에, 자식 클래스는 부모 클래스보다 더 적은 멤버를 가질 수 없다.
자식 클래스의 멤버 ≥ 부모 클래스의 멤버 의 관계는 항상 성립하고 불변이다.
또한 설명을 들으면서 의문이었던 점은 그럼 상속받은 객체는 힙에 어떻게 저장될까? 에 대해서 궁금했는데, 신기하게도 기존에 객체를 생성하던 것처럼 상속받은 객체도 부모의 객체를 생성하던 것처럼 부모의 멤버들이 그대로 자식으로 물려져서 어디 다른곳에 생기는게 아니고 기존의 자식 클래스가 그 멤버를 가지고 있던 것처럼 메모리에 적재된다고 한다.
포함
포함(composite) 관계가 나오게 되었다. 최근에 디자인패턴을 공부하면서도 나왔는데 거기서는 구성이라는 단어를 쓴다. 아직도 기억이 나지만 A에는 B가 있다 로 표시 한다. 언어에 혼동이 있을 수 있기 때문에 Composite라고 서술하겠다.
Composite는 어려운 것이 아니고 단순히 클래스의 멤버 변수로 참조 변수를 선언하는 것이다. 이렇게 얘기를 하면 헷갈릴 수 있는데 예시를 한 번 살펴보자.
class Point {
int x;
int y;
}
class Circle {
Point p = new Point();
int r;
}
Circle이 Point를 Composite 하고 있는 것이다. 물론 위에서 상속을 배웠을 때라면 extends를 써서 상속을 받을 수도 있지만, 가만히 생각해보자. 점의 자식이 원..? 말도 안된다. 따라서 강의에서도 얘기하지만 상속은 자주 사용되지 않고 정말 필요할 때만 사용되고 대부분은 Composite 관계인 것들이 많다고 한다. 실제로 개발을 할 때에 있어서도 상속을 하기보다 단일책임을 갖는 클래스를 갖고, 그리고 그 클래스들이 다른곳에 구성되어 그 역할을 같이 수행할 수 있도록 한다.
메모리 단위에서 접근을 하게 되었을 때의 그림을 살펴보자. 상속을 받았을 때는 객체에 대해 충분한 메모리 공간을 확보하고 각각에 대한 메모리에 바로 간접 참조가 가능했는데, 이제는 CPU에서 MMU를 통해 메모리에 간접 참조하고 그 접근한 메모리에 있는 데이터가 주소일 경우 한 번 더 간접 참조를 하는 것처럼 다음과 같은 그림이 나오게 된다.
이렇게 연결리스트 관계처럼 이어지는 것을 볼 수 있다. 기존과 다른 새로운 구조인데 전혀 어렵지 않고 참조 변수는 참조 주소를 저장하기 때문에 이와 같은 그림이 나오게 되는 것이다.
그럼 언제 상속을 하고 언제 Composite를 해야 되는지에 대해서 고민을 해야하는 상황이 생길 수도 있는데, 간단한 구분법을 제시해주었다. 물론 이 방법이 100% 맞는 것은 아니지만 어떤 구조로 흘러가야 하는지는 눈에 보일 것이다.
상속관계 : ~은 ~이다. 포함(구성)관계 : ~은 ~을 가지고 있다.
이렇게만 보면 무슨 말인지 잘 이해가 안될 수도 있는데 이것도 예시를 한 번 보는 것이 깔끔할 것이다.
class Point {
int x;
int y;
}
class Circle {
Point p = new Point();
int r;
}
아까 봤던 예시이다. 만약 상속을 해야하는 경우라면 원은 점이다? 뭔가 이상하다. 원은 여러개의 점으로 구성되어있다고 얘기를 하지 않는가? 맞다 따라서 원은 점을 가지고 있고, 원은 여러개의 점으로 구성되어있다. 따라서 포함관계가 되어야 하는 것이다. 객체 지향 언어이기 때문에 생각보다 현실 세계에 있는 것들을 대입해서 얘기를 하면 어떤 관계인지 잘 이해할 수 있을 것이다.
참고 해야 할 것은 이제 객체 안에 또 다른 객체가 존재하는 것이나 다름이 없기 때문에 변수에 접근하는 방식도 달라져야 한다.
// 위에 있는 Circle 클래스가 있다고 가정
public class Main {
public static void main(String[] args) {
Circle c = new Circle(); // 생성자가 없기 떄문에 컴파일러가 기본 생성자 추가
c.p.x = 1; // 객체 c 내부에 있는 객체 p에 접근해서 객체 p의 인스턴스 변수 x에 접근
c.p.y = 2; // 객체 c 내부에 있는 객체 p에 접근해서 객체 p의 인스턴스 변수 y에 접근
c.r = 1; // 객체 c 내부에 있는 인스턴스 변수 r에 접근
}
}
객체에 두 번 접근해서 값을 가지고 오는 것을 볼 수 있다. 머리속으로 메모리 구조를 먼저 그려보고 혹은 떠오르지 않는다면 직접 그려보면서 어떻게 적재되는지 생각을 하면 더 편할 것이다.
단일 상속
C++과 달리 자바는 단일 상속만 받는다. 추후에 인터페이스를 배우게 되는데 인터페이스는 다중 구현이 가능하다. 왜 다중 상속을 자바는 채택하지 않았을까. 물론 여러가지가 있지만 다중 상속에 가장 큰 문제는 다음과 같다.
class Dvd {
boolean play;
}
class Blueray {
boolean play;
}
class Video extends Dvd, Blueray { // 다중 상속은 안되지만 그냥 예시이다.
}
이때, 비디오는 어떤 play를 상속받아야 하는지 모른다. 다중 상속에는 이러한 문제가 있어서 채택을 하지 않은 것이다. 하지만 우리가 그럼에도 불구하고 동일한 매서드를 사용해야 될 때가 있는데, 이럴 때 사용하라고 있는 것이 Composite 관계이다.
class Tv {
boolean power;
int channel;
void power() {power = !power;}
void channelUp() {channel++;}
void channelDown() {channel--;}
}
class DVD {
boolean power;
void power() {power = !power;}
void play() {}
void stop() {}
void rew() {}
}
class DVDTv extends TV {
DVD d = new DVD();
void play() {
d.play();
}
void stop() {
d.stop();
}
//...
}
위 처럼 가장 비중이 높은 클래스에 대해서만 상속을 받고 세부적인 내용에 대해서는 포함 관계를 통해 메서드에 DVD 클래스에 있는 메서드를 사용하도록 하면 서로 부딪힐 일도 없이 구현이 된다. 이러한 특징은 다음에 배울 오버라이딩에서 똑똑히 드러난다.
Object 클래스 - 모든 클래스의 조상
Object가 모든 클래스의 조상이라는 사실을 분명 알고 있었지만 실제로 전체 클래스가 어떻게 Object를 상속받고 있는 것일까에 대해서는 참 의문이었다. 하지만 분명히 책에 적혀있음에도 불구하고 러프하게 학습했다.
class Tv {}
class SmartTv extends Tv {}
다음과 같이 구성이 되어있다고 하자. 지금 Tv를 보면 아무것도 상속되지 않은 것을 볼 수 있는데, 이 때 클래스에 아무것도 상속하지 않으면 컴파일러가 자동으로 extends Object를 추가해준다. 마치 기본 생성자가 자동으로 추가되는 것과 같은 의미이다. 조상이 없는 클래스는 자동적으로 Object 클래스를 상속받는다는 것을 잊지 말자. 이제 상속에서 왜 부모 자식이라고 하지 않고 조상과 자손이라고 하는지 이해가 될것이다. 다음과 같은 클래스의 상속 계층도를 그리면 다음과 같은 그림이 나온다. 우리는 조상으로부터 유전자를 물려받는 것과 같은 개념으로 봐야한다.
TV는 Object를 상속받았다. 그럼 당연하게도 TV는 Object 클래스가 가지고 있는 기능을 사용할 수 있는데 그 기능에 TV만이 가지고 있는 기능을 추가하여 또 DVD에게 보내주었다. 그럼 자연스레 DVD도 Object의 기능을 사용할 수 있는 것이다.
Object 클래스에는 총 11개의 메서드가 존재한다.
공식 문서를 확인하면 11개의 메서드를 확인할 수 있다.
참고로 자주 사용하는 것이 toString() 메서드인데, toString()을 오버라이딩 하지 않은 상태에서 출력을 보면 다음과 같이 나온다. 클래스명@객체해시주소 그런데 System.out.print(객체); 만 써도 동일하게 출력이 되는 것을 볼 수 있는데 그 이유는 다음과 같다.
public void println(Object x) {
String s = String.valueOf(x);
synchronized (this) {
print(s);
newLine();
}
}
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
메서드 호출을 거슬러 올라가보면 println에 매개변수에 참조변수가 들어오면, String.valueOf 메소드에 참조변수를 전달하고, null 이 아니면 Object 클래스에 있는 toString() 을 호출하는 것을 볼 수 있다. 따라서 toString()을 사용하는 것과, System.out.print(객체) 는 동일한 내용을 출력하는 것이다.
오버라이딩
처음에 객체지향 개념을 배울 때 오버로딩과 헷갈리는 오버라이딩이다. overload는 과적합이기 때문에 같은 메소드명을 가진 메서드를 일정 조건에 따라 여러 개 생성할 수 있음을 나타내듯이 overriding인 이유는 override가 덮어쓴다는 의미를 가지고 있기 때문이다. 이런 의미를 생각하고 정의를 보면 다음과 같다.
오버라이딩은 상속받은 조상의 메서드를 자신에 맞게 새로 정의하는 것 혹은 변경하는 것이다. 다음과 같은 예시를 살펴보자
class Point {
int x;
int y;
void getLocation() {
System.out.println("x : " + x + ", y : " + y);
}
}
class Point3D extends Point {
int z;
}
public class Main {
public static void main(String[] args {
Point3D point = new Point3D();
point.getLocation(); // x : 0, y : 0
}
}
나는 Point 클래스를 상속받은 Point3D 클래스에서 Point로 부터 상속받은 getLocation() 메서드를 호출했을 때, x, y, z가 전부 출력되기를 원했는데, 조상 클래스 입장에서는 자손 클래스가 무엇을 가지고 있는지 알 방도가 없다. 이럴 때 사용할 수 있는 것이 오버라이딩이다.
// 이미 Point는 선언이 되있다고 가정
class Point3D extends Point {
int z;
@Override // 생략가능
void getLocation() {
System.out.println("x : " + x + ", y : " + y + ", z : " + z);
}
}
위 처럼 부모의 메서드를 자손에서 새로이 자신의 클래스의 필요성에 맞게 정의 할 수 있다. 위에 코드를 보면 @Override 를 하여 해당 메서드는 부모의 메서드를 오버라이딩 한 것이라고 명시할 수 있는 애노테이션이 있는데, 코드는 다른 사람과 협업하면서 가독성과 코드를 보고 다른 사람이 이해 할 수 있도록 하는 것이 중요하기 때문에, 명시해주는 것이 좋다.
오버로딩도 오버로딩 할 수 있는 조건이 있던 것 처럼 오버라이딩도 오버라이딩을 하기 위한 조건이 있다.
- 선언부가 조상 클래스의 메서드와 일치해야 한다.
- 선언부에는 반환 타입, 메소드 명, 매개변수들이 있는데, 이게 모두 일치해야 된다는 것이다.
- 접근 제어자를 조상 클래스의 메서드보다 좁은 범위로 변경할 수 없다.
- 예외는 조상 클래스의 메서드보다 많이 선언할 수 없다. 자식이 부모보다 더 많은 예외를 보낼 수 없다는 것이다. 자손이 사고치면 부모가 그 사고들을 최소한으로는 다 해결해줘야 된다고 이해하면 편하다.
이 때, 한 가지 테스트 해보고 싶은게 있었는데, 지금 생각해보면 당연한 거지만 부모 클래스에 있는 메소드를 오버라이딩하지않고 자식에서 바로 오버로딩해봤는데 예상했던 것과 같이 오버로딩이 되는 모습을 확인 할 수 있었다.
마지막으로 오버로딩과 오버라이딩은 아예 별개의 이야기이다. 이름이 비슷하다고 해서 혼동하지 말고 각각의 개념에 대해서 정확하게 인지하자.
'Language > Java' 카테고리의 다른 글
7-3 패키지 (0) | 2023.03.22 |
---|---|
7-2 참조변수 super, 생성자 super() (0) | 2023.03.22 |
6. 객체지향언어 1 (0) | 2023.03.22 |
3. 연산자 (0) | 2023.03.21 |
2. 변수 (0) | 2023.03.21 |