[자바의 정석] CH07 객체지향 프로그래밍Ⅱ (1)
자바의 정석 책을 공부하고 정리한 글입니다!
혹시라도 틀린 부분이 있다면 친절하게 알려주세요.
감사합니다!
CH7. 객체지향 프로그래밍 Ⅱ
상속(Inheritance)
상속이란 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것입니다. 상속을 통해 클래스를 작성하면 다음과 같은 특징이 있습니다:
- 적은 양의 코드로 새로운 클래스를 작성할 수 있다.
- 코드를 공통적으로 관리할 수 있어 코드의 추가 및 변경이 용이하다.
이러한 특징은 코드의 재사용성을 높이고, 프로그램의 생산성과 유지보수에 크게 기여합니다.
자바에서 상속을 구현할 때는 extends 키워드를 사용합니다.
class 클래스명 extends 부모클래스명 {
}
예를 들어 탈것을 정의한 Vehicle 클래스를 상속받는 Car라는 클래스를 만들어 보겠습니다.
class Vehicle {
public void start() {
System.out.println("시동을 켭니다.");
}
public void stop() {
System.out.println("시동을 끕니다.");
}
}
class Car extends Vehicle {
public void honk() {
System.out.println("빵빵!");
}
}
Vehicle 클래스와 Car 클래스는 서로 상속 관계에 있다고 하며, 상속해주는 클래스를 부모 클래스, 상속 받는 클래스를 자식 클래스라고 합니다.
서로 상속 관계에 있는 두 클래스를 다음 용어처럼 사용하기도 합니다.
- 부모 클래스 (= 조상 클래스, 상위 클래스, 기반 클래스)
- 자식 클래스 (= 자손 클래스, 하위 클래스, 파생된 클래스)
자식 클래스는 부모 클래스의 모든 멤버를 상속받습니다. 따라서 자식 클래스는 부모 클래스의 멤버를 모두 포함하고 있으며, 다음과 같이 표현할 수 있습니다.
만약, 부모 클래스에서 멤버를 추가하면 자식 클래스에는 자동적으로 영향을 받습니다. (자식 클래스는 부모의 모든 것을 상속받기 때문) 그러나 자식 클래스에서 멤버를 추가해도 부모 클래스는 아무런 영향도 받지 않습니다. 즉, 자식 클래스는 부모 클래스와 멤버가 같거나, 더 많은 멤버를 갖게 됩니다. 그래서 상속받는 다는 것은 부모 클래스를 확장(extend)한다는 의미로 해석할 수도 있으며, extends 키워드를 사용하는 이유이기도 합니다.
Vehicle을 상속받는 Bike 클래스를 새롭게 정의해보겠습니다.
class Vehicle {
public void start() {
System.out.println(brand + " 시동을 켭니다.");
}
public void stop() {
System.out.println(brand + " 시동을 끕니다.");
}
}
class Car extends Vehicle {
public void honk() {
System.out.println("빵빵!");
}
}
class Bike extends Vehicle {
public void ringBell() {
System.out.println("따르릉~");
}
}
그럼 Vehicle, Car, Bike 클래스는 모두 start(), stop() 갖게 됩니다. 만약 Vehicl클래스에서 stop() 이라는 메서드를 지우게 되면, 자식 클래스에서도 지워지게 되겠죠?
위에서 언급한 것과 같이, 부모 클래스만 변경해도 자식 클래스에까지 영향을 미치기 때문에 자식 클래스의 공통적인 부분은 부모 클래스에서 관리하고, 자식 클래스는 자신에게 정의된 멤버들만 관리하면 됩니다.
참고로 형제관계 이런 건 없습니다. Bike클래스와 Car클래스는 아무런 관계도 성립되지 않습니다.
또한, 자식 클래스의 인스턴스를 생성하면 부모 클래스의 멤버도 함께 생성되기 때문에 따로 부모 클래스의 인스턴스를 생성하지 않아도 부모 클래스의 멤버들을 사용할 수 있습니다.
자바에서 클래스가 다른 클래스를 상속 받을 때는 오직 하나의 클래스만 상속받을 수 있습니다(단일상속). 다중상속을 허용하면 여러 클래스로부터 상속받을 수 있기 때문에 복합적인 기능을 가진 클래스를 쉽게 작성할 수 있다는 장점이 있습니다. 하지만 클래스간의 관계가 매우 복잡해진다는 단점을 갖고 있어 단일상속만 허용합니다.
상속관계와 포함관계
상속 외에도 클래스를 재사용하는 또 다른 방법이 있는데, 클래스 간에 포함(Composite)관계를 맺어주는 것입니다. 클래스 간의 포함관계를 맺어 주는 것은 한 클래스의 멤버변수로 다른 클래스 타입의 참조변수를 선언하는 것을 뜻합니다.
원을 표현하기 위한 Circle이라는 클래스와 좌표를 포현하는 Point 클래스가 있다고 가정해봅시다.
class Circle {
int x; // 원점의 x좌표
int y; // 원점의 y좌표
int r; // 반지름
}
class Point {
int x;
int y;
}
Point를 재사용하여 Circle을 작성하면 다음과 같이 할 수 있습니다.
class Circle {
Point c = new Point(); // 포함 관계
int r;
}
이처럼 하나의 거대한 클래스를 작성하는 것보다는 단위별로 여러 개의 클래스를 작성한 다음, 이 단위 클래스들을 포함 관계로 재사용하면 보다 쉽게 클래스를 작성할 수 있습니다.
그럼 언제 상속 관계를, 언제 포함 관계를 맺어주면 좋을까요?
어떤 관계가 알맞은 건지 모를 때는, 다음을 참고하여 문장을 만들어보세요.
- 상속 관계(is-a): A는 B이다. (A is a B)
- 포함 관계(has-a): A는 B를 가지고 있다. (A has a B)
Circle과 Point로 문장을 만들어 보면 다음과 같습니다.
- 원(Circle)은 점(Point)이다. (is-a)
- 원(Circle)은 점(Point)을 가지고 있다. (has-a)
첫 번째 문장보다는 두 번째 문장이 더 자연스럽죠? 이런 경우에는 포함 관계를 맺어주시면 됩니다.
그럼 Vehicle과 Car로 문장을 만들어 보겠습니다.
- 자동차(Car)는 탈 것(Vehicle)이다. (is-a)
- 자동차(Car)는 탈 것(Vehicle)을 가지고 있다. (has-a)
이 경우에는 첫 번째 문장이 더 자연스럽죠? 그러니 상속 관계를 맺어주는 것이 좋습니다.
물론 한 클래스에서 상속 관계, 포함 관계를 둘 다 맺을 수도 있습니다!
Object 클래스: 모든 클래스의 조상
Object 클래스는 모든 클래스 상속계층도의 최상위에 있는 부모 클래스입니다! 만약 다른 클래스를 상속받지 않는 클래스에게는 컴파일러가 자동으로 extends Object를 추가하여 Object 클래스를 상속받도록 합니다.
위에서 사용한 Vehicle 클래스와 Car 클래스로 예시를 들어보겠습니다.
class Vehicle {
public void start() {
System.out.println(brand + " 시동을 켭니다.");
}
public void stop() {
System.out.println(brand + " 시동을 끕니다.");
}
}
class Car extends Vehicle {
public void honk() {
System.out.println("빵빵!");
}
}
Car 클래스의 경우, 이미 Vehicle 클래스를 상속하고 있어, 컴파일 과정에서 extends Object를 붙여주지 않습니다. (만약 자동으로 Object를 상속하게 되면 단일 상속이 아닌, 다중 상속이 되어버립니다. 모순이 생기게 되겠죠?) 그러나 Vehicle 클래스는 아무것도 상속받고 있지 않으므로 컴파일러가 extends Object 를 자동으로 붙여주게 되죠.
Object 클래스는 모든 클래스의 부모 클래스라 설명했는데, Car 클래스는 어떻게 Object 클래스의 자식 클래스가 될까요?
자바에서는 상속을 계층 구조로 해석합니다. 그래서 Car는 Vehicle의 모든 멤버를 상속하고, Vehicle은 Object의 멤버를 상속합니다.
결과적으로 Car는 간접적으로 Object의 멤버도 상속받게 되며, Car → Vehicle → Object 순서로 상속 계층이 형성됩니다! 따라서 자바에서 정의된 모든 클래스는 Object 클래스에 정의된 모든 멤버를 사용할 수 있답니다!
오버라이딩(overriding)
오버라이딩이란 부모 클래스로부터 상속받은 메서드의 내용을 변경(즉, 재정의)하는 것을 말합니다. 코드를 작성하다 보면, 부모 메서드의 내용을 그대로 사용할 수도 있지만, 자식 클래스에 맞게 변경해야하는 경우도 생깁니다. 이럴 때 부모의 메서드를 오버라이딩합니다.
오버라이딩이 성립하기 위해서는 다음과 같은 조건을 만족해야 합니다:
자식 클래스에서 오버라이딩하는 메서드는 부모 클래스의 메서드와
- 이름이 같아야 한다.
- 매개변수가 같아야 한다.
- 반환타입이 같아야 한다.
즉, 선언부가 서로 일치해야 한다는 것이죠. 다만 접근 제어자와 예외는 제한된 조건 하에서만 다르게 변경할 수 있습니다.
부모 클래스의 메서드를 자식 클래스에서 오버라이딩할 때,
- 접근 제어자를 부모 클래스의 메서드보다 좁은 범위로 변경할 수 없다.
- 예외는 조상 클래스의 메서드보다 많이 선언할 수 없다.
- 인스턴스 메서드를 static 메서드로 또는 그 반대로 변경할 수 없다.
부모 클래스에 정의된 static 메서드르 자식 클래스에서 똑같은 이름의 static 메서드로 정의하는 것은 가능합니다. 하지만 각 클래스에 별개의 static 메서드를 정의한 것일 뿐, 오버라이딩은 아닙니다. static 멤버들은 자신들이 정의된 클래스에 묶여있다고 생각하시면 됩니다!
[참고] 오버로딩과 오버라이딩은 다른 것입니다. 헷갈리지 마세요!
오버로딩(overloading) - 메서드 이름은 같지만 매개변수의 개수나 타입이 다른 메서드를 여러 개 정의하는 것 (중복정의)
오버라이딩(overriding) - 부모에게 상속받은 메서드의 내용을 변경하는 것 (재정의)
super() / super
this()와 마찬가지로 super() 역시 생성자이며, 부모 클래스의 생성자를 호출할 때 사용합니다.
자식 클래스의 인스턴스를 생성하면, 자식의 멤버와 부모의 멤버가 모두 합쳐진 하나의 인스턴스가 생성되며, 자식 클래스의 인스턴스가 부모 클래스의 멤버들을 사용할 수 있습니다. 이 때, 부모 클래스 멤버의 초기화 작업이 수행되어야 하기 때문에 super()를 통해 부모 생성자를 호출하는 것이죠.
Object클래스를 제외한 모든 클래스의 생성자 첫 줄에는 this() 또는 super()를 호출해야 합니다. 그렇지 않으면 컴파일러가 자동적으로 super();를 생성자의 첫 줄에 삽입합니다.
class Animal {
Animal() {
System.out.println("동물 생성자 호출");
}
}
class Dog extends Animal {
Dog() {
// 여기 아무 것도 안 써도 자동으로 super(); 가 들어감
System.out.println("강아지 생성자 호출");
}
}
// [출력]
// 동물 생성자 호출
// 강아지 생성자 호출
super는 자식 클래스에서 부모 클래스를 지칭할 때 사용하는 키워드입니다. 클래스 내에서 멤버변수와 지역변수의 이름이 같을 때 this를 사용하여 구별했듯이 상속받은 멤버와 자신의 멤버가 이름이 같을 때는 super 키워드를 사용합니다. 물론 부모 클래스로부터 상속받은 멤버도 자식 클래스의 멤버이므로 super 대신 this를 사용할 수 있습니다! 그러나 멤버가 중복 정의되어 구별해야할 경우에는 super를 사용하는 것이 좋습니다.
class SuperTest {
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.x); // 10 출력
}
}
package
패키지란 클래스 묶음을 말합니다. 패키지에는 클래스 또는 인터페이스(뒤에서 설명할 개념)를 포함시킬 수 있으며, 서로 관련된 클래스들끼리 그룹 단위로 묶어 놓음으로써 클래스를 효율적으로 관리할 수 있습니다. 같은 이름의 클래스도 패키지가 다르면 존재할 수 있습니다.
지금까지는 단순히 클래스 이름으로만 클래스를 구분했지만, 클래스의 full name은 패키지명을 포함한 것입니다.
- 하나의 소스 파일에는 첫 번째 문장으로 단 한 번의 패키지 선언만을 허용한다.
- 모든 클래스는 반드시 하나의 패키지에 속해야 한다.
- 패키지는 점을 구분자로 하여 계층구조를 구성한다.
- 패키지는 물리적으로 클래스 파일을 포함한 하나의 디렉토리이다.
import문
소스코드를 작성할 때 다른 패키지의 클래스를 사용하려면 패키지명이 포함된 클래스 이름을 사용해야합니다. 하지만, 매번 패키지명을 붙여 작성하는 것은 불편한 일이죠. 코드를 작성하기 전에 사용하고자 하는 클래스의 패키지를 import문으로 미리 명시해주면 소스코드에 사용되는 클래스이름에서 패키지명을 생략할 수 있습니다!
일반적인 소스파일(*.java)의 구성은 다음의 순서로 되어 있습니다.
- pakage문
- import문
- 클래스 선언
import문을 선언하는 방법은 다음과 같습니다.
import java.util.Calendar;
import java.util.ArrayList;
import java.util.*; // * 로 지정하면 java.util 하위에 있는 클래스는 모두 사용 가능
제어자(modifier)
제어자는 클래스, 변수, 메서드의 선언부에 함께 사용되어 부가적인 의미를 부여하며, 종류는 크게 접근 제어자와 그 외의 제어자로 나눌 수 있습니다.
- 접근 제어자: public, protected, default, private
- 그 외 제어자: static. final, abstract, native, transient,synchronized, volatile, strictfp
제어자는 하나의 대상에 여러 제어자를 조합하여 사용할 수 있습니다. 그러나 접근 제어자는 한 번에 하나만 사용할 수 있습니다.
// 메인 메서드: 두 개의 제어자(public, static) 같이 사용
public static void main(String[] args)
static
static은 '클래스의' 또는 '공통적인'의 의미를 갖고 있습니다. 따라서 static이 붙은 멤버 변수와 메서드, 그리고 초기화 블럭은 인스턴스가 아닌 클래스에 관계된 것이기 때문에 인스턴스를 생성하지 않고도 사용할 수 있습니다.
fianl
final은 '마지막의' 또는 '변경될 수 없는'의 의미를 가지고 있으며, 거의 모든 대상에 사용될 수 있습니다. 변수에 사용되면 값을 변경할 수 없는 상수가 되며, 메서드에 사용되면 오버라이딩을 할 수 없게 되고, 클래스에 사용되면 자신을 확장하는 자식 클래스를 정의하지 못하게 됩니다. 즉, final로 지정된 클래스는 다른 클래스의 부모가 될 수 없습니다.
final이 붙은 변수는 상수이므로 일반적으로 선언과 초기화를 동시에 하지만, 인스턴스 변수의 경우 생성자에서 초기화가 가능합니다.
abstract
abstract는 '미완성'의 의미로, 메서드의 선언부만 작성하고 실제 수행내용은 구현하지 않은 추상 메서드를 선언하는데 사용됩니다. 추상 클래스는 아직 완성되지 않은 메서드가 존재하는 미완성 설계도이므로 인스턴스를 생성할 수 없습니다.
접근 제어자(access modifier)
접근 제어자는 멤버 또는 클래스에 사용되어, 해당하는 멤버 또는 클래스를 외부에서 접근하지 못하도록 제한하는 역할을 합니다. 지금까지 예제에서는 접근 제어자를 지정하지 않았는데, 접근 제어자가 default임을 뜻합니다.
- private: 같은 클래스 내에서만 접근 가능 (접근 범위가 좁음)
- default: 같은 패키지 내에서만 접근 가능
- protected: 같은 패키지 내에서, 그리고 다른 패키지의 자식 클래스에서 접근 가능
- public: 접근 제한이 전혀 없다 (접근 범위가 넓음)