자바의 정석 7. 객체지향 프로그래밍2
상속
상속의 정의와 장점
상속이란, 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것이다.
1
2
3
class Child extends Parent{
}
상속되는 경우 생성자와 초기화 블록은 상속되지 않는다. 멤버만 상속된다. 자손 클래스의 멤버 개수는 조상 클래스보다 항상 같거나 많다.(확장가능하기 때문)
클래스간의 관계 - 포함관계
클래스 간의 포함 관계를 맺어주는 것은 한 클래스의 멤버변수로 다른 클래스 타입의 참조 변수를 선언하는 것이다.
1
2
3
4
class Circle{
Point c = new Point(); // Point는 Circle에 포함되어 있다.
int r;
}
클래스간의 관계 결정하기
클래스간의 관계를 결정할 때는 다음 방식을 활용한다.
상속관계 : ~은 ~이다. 포함관계 : ~은 ~을 가지고 있다.
예를 들어 원
과 점
의 관계를 결정할 때, 원은 점이다
는 어색하고 원은 점을 가지고 있다
가 맞는 표현이므로 포함관계로 정하게 된다.
단일 상속(single inheritance)
자바클래스에서는 오직 단일 상속만을 허용한다. 다중 상속에 비해 불편한 점이 존재하지만 클래스 간의 관계가 명확해지고 코드를 더욱 신뢰할 수 있도록 해준다.
포함관계를 이용해 다중상속을 어느 정도 따라 할 수 있다.
오버라이딩(overriding)
오버라이딩이란?
오버라이딩이란 조상으로부터 상속 받은 메서드의 내용을 변경하는 것이다. 오버라이딩이 가능하기 위해선 다음 조건을 만족해야 한다.
- 이름이 같아야 한다.
- 매개 변수가 같아야 한다.
- 반환 타입이 같아야 한다.
오버라이딩은 메서드의 내용만을 새로 작성하는 것이므로 메서드의 선언부는 조상의 것과 완전히 일치해야 한다. 그래서 오버라이딩이 성립하기 위해서, 오버라이딩하는 메서드는 조상의 매서드와 이름이 같아야 하고, 매개변수가 같아야 하고, 반환 타입이 같아야 한다.
super
super는 자손 클래스에서 조상 클래스로부터 상속 받은 멤버를 참조하는데 사용되는 참조변수이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Point {
int x;
int y;
String getLocation(){
return "x :" + x ", y : " + y;
}
}
class Point3D extends Point{
int z;
String getLocation(){
return super.getLocation() + ", z : " + z; // 조상의 메서드 호출
}
}
super() - 조상 클래스의 생성자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class PointTest{
public static void main(String args[]){
Poinst3D p3 = new Point3D(1,2,3);
}
}
class Point {
int x,y;
Point(int x, int y){
this.x = x;
this.y = y;
}
String getLocation(){
return "x : " + x + ", y : " + y;
}
}
class Point3D extends Point{
int z;
Point3D(int x, int y, int z){
super(); // 상속을 받기 때문에
this(x,y,z);
}
String getLocation(){
return "x : "+ x + ", y :" + y + ", z :" + z;
}
}
this()와 마찬가지로 super() 역시 생성자이다.
자손 클래스의 인스턴스를 생성하면, 자손이 멤버와 조상의 메ㅁ버가 모두 합쳐진 하나의 인스턴스가 생성된다. 이 때 조상 클래스 멤버의 초기화 작업이 숭행되어야 하기 때문에 자손 클래스의 생성자에서 조상 클래스의 생성자가 호출되어야 한다.
제어자(modifier)
제어자란?
제어자(modifier)는 클래스, 변수 또는 메서더의 선언부와 함께 사용되어 부가적인 의미를 부여한다. 제어자의 종류는 크게 접근 제어자와 그 외의 제어자로 나눌 수 있다.
- 접근 제어자 : public, protected, default, private
- 그 외 : static, final, abstract, native, transient, synchronized, volatile, strictfp
static - 클래스의 공통적인
static은 ‘클래스의’ 또는 ‘공통적인’의 의미를 가지고 있다. 클래스 변수(static 변수)는 모든 인스턴스가 공유한다.
static이 사용될 수 있는 곳 - 멤버변수, 메서드, 초기화 블록
final - 마지막의 변경 될 수 없는
final은 클래스, 메서드, 멤버변수, 지역 변수에 사용될 수 있다. final이 붙은 변수는 상수이므로 일반적으로 선언과 초기화를 동시에 하지만, 인스턴스 변수의 경우 생성자에서 초기화가 가능하다.
abstract - 추상의, 미완성의
메서드의 선언부만 작성하고 실제 수행내용은 구현하지 않는다. abstract 클래스의 경우 인스턴스를 생성할 수 없다.
접근 제어자(access modifier)
접근 제어자는 멤버 또는 클래스에 사용되어, 해당하는 멤버 또는 클래스를 외부에서 접근하지 못하도록 제한하는 역할을 한다.
- private : 같은 클래스 내에서만 접근이 가능하다.
- default : 같은 패키지 내에서만 접근이 가능하다.
- protected : 같은 패키지 내에서, 그리고 다른 패키지의 자손클래스에서 접근이 가능하다.
- public : 접근 제한이 전혀 없다.
생성자의 접근 제어자
생성자에 접근제어자를 사용함으로써 인스턴스의 생성을 제한할 수 있다. 생성자의 접근 제어자를 private
으로 지정하면, 외부에서 생성자에 접근할 수 없으므로 인스턴스를 생성할 수 없게 된다. 생성자가 private
인 경우 클래스 이름 앞에 final
을 추가하여 상속 할 수 없다는 것을 알리는 것이 좋다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class final Singleton{
private static Singleton s = new Singleton(); // getInstance()에서 사용 될 수 있도록 static이어야 한다.
private Singleton(){
...
}
// 인스턴스를 생성하지 않고도 호출할 수 있어야 하므로 static이어야 한다.
public static Singleton getInstance(){
return s;
}
...
}
생성자가 private
인 클래스는 생성자 호출이 안되기 때문에 다른 클래스의 조상이 될 수 없다.
다형성
다형성이란?
객체지향개념에서 다형성이란 ‘여러 가지 형태를 가질 수 있는 능력’을 의미하며, 자바에서는 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있도록 함으로써 다형성을 포르개름적으로 구현하였다.
타입의 참조변수로 여러 타입 객체를 무조건 참조가 가능하게 하는 것은 아니고
조상 클래스 타입
의 참조변수로자손 클래스의 인스턴스
를 참조 할 수 있다.
1
2
3
4
5
class Tv{}
class CaptionTv extends Tv{}
Tv t = new CaptionTv(); // 가능
CaptionTv c = new Tv(); // 불가능!
Tv t = new CaptionTv();
와 Caption c = new CaptionTv()
에는 차이가 있다. 둘 다 같은 인스턴스를 가리키고 있지만 참조변수의 타입에 다라 사용할 수 있는 멤버의 개수가 달라진다. 따라서 부모 클래스 타입의 참조변수는 자식 클래스 타입의 참조변수 보다 접근 할 수 있는 멤버변수가 적다.
조상타입의 참조변수로 자손타입의 인스턴스를 참조할 수 있다. 반대로 자손타입의 참조변수로 조상타입의 인스턴스를 참조할 수는 없다.
참조변수 형변환
- 조상타입 -> 자손타입(Down - Casting) : 사용가능 멤버변수가 증가하기 때문에 형변환 생략 불가
- 자손타입 -> 조상타입(Up - Casting) : 사용가능 멤버변수가 감소하기 때문에 형변환 생략 가능
1
2
3
4
5
Car car = null;
FireEngine fe = new FireEngine();
FireEngine fe2 = null;
car = fe; // car = (Car)fe;에서 형변환 생략됨. 업캐스팅
fe2 = (FireEngine) car; // 형변환을 생략불가. 다운 캐스팅
형변환 참조변수의 타입을 변환하는 것이지 인스턴스를 변환하는 것은 아니기 때문에 참조변수의 형변환은 인스턴스에 아무런 영향을 미치지 않는다. 조상타입 인스턴스를 가리키고 있는 조상타입의 참조변수를 자손타입의 참조변수로 형변환하는 경우 에러가 발생할 수 있다는 것에 주의하자.
참조변수와 인스턴스의 연결
조상 클르새에 선언된 멤버변수와 같은 이름의 인스턴스변수를 자손 클래스에 중복으로 정의 했을 때, 조상타입의 참조변수로 자손 인스턴스를 참조하는 경우와 자손타입의 참조 변수로 자손 인스턴스를 참조하는 경우는 서로 다른 결과를 얻는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Parent {
int x = 10;
void show() {
System.out.println("Parent's show()");
}
}
class Child extends Parent {
int x = 20; // Parent 클래스의 x 필드를 숨깁니다 (hiding)
@Override
void show() {
System.out.println("Child's show()");
System.out.println(this.x);
System.out.println(super.x);
}
}
public class Main {
public static void main(String[] args) {
Parent p = new Child(); // 다형성: 부모 타입의 참조 변수가 자식 객체를 참조
System.out.println(p.x); // 10
p.show(); // Child's show()
// 20
// 10
}
}
위 코드를 보면 메서드의 경우 조상 클래스의 메서드를 자손의 클래스에서 오버라이딩한 경우에도 참조 변수의 타입에 관계없이 항상 실제 인스턴스의 메서드(오버라이딩된 메서드)가 호출되지만, 멤버변수의 경우 참조변수의 타입에 따라 달라진다.
매개변수의 다형성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Product{
int price; // 제품의 가격
int bonusPoint; // 제품구매 시 제공하는 보너스 점수
}
class Tv extends Product{}
class Computer extends Product{}
class Audio extends Product{}
class Buyer {
int money = 1000;
int bonusPoint = 10;
void buy(){
}
}
위 코드에서 만약 Tv, Computer, Audio 각각의 인스턴스를 각각의 타입으로 참조변수를 선언한다면 Buyer 클래스의 buy메서드는 각각의 타입별로 존재해야 할 것이다.
1
2
3
void buy(Tv t){}
void buy(Computer c){}
void buy(Audio a){}
하지만 Product 타입의 참조변수로 각각의 인스턴스를 생성하면 메서드의 매개변수에 다형성으로 인해 아래와 같이 하나의 메서드로 간단히 처리할 수 있다.
1
void buy(Product p){}
추상클래스(abstract class)
추상클래스란?
1
abstract class name{}
클래스를 설계도에 비유한다면, 추상클래스는 미완성 설계도에 비유할 수 있다. 클래스가 미완성이라는 것은 멤버의 개수에 관계된 것이 아니라, 단지 미완성 메서드(추상메서드)를 포함하고 있다는 의미이다. 미완성이기 때문에 인스턴스 생성이 불가하다.
추상 메서드
1
2
3
abstract class name{
abstract void play();
}
추상 메서드는 선언만 하고 미완성으로 남겨둔 메서드이다. 미완성으로 남겨 놓는 이뉴는 메서드의 내용이 상속받는 클래스에 따라 달라질 수 있기 때문이다.
실제 작업내용인 구현부가 없는 메서드가 무슨 의미가 있을까 싶기도 하겠지만, 메서드를 작성할 때 실제 작업내용인 구현부보다 더 중요한 부분이 선언부이다.
추상 클래스를 상속받은 클래스는 추상 메서드를 모두 오버라이드하여 자신의 클래스에 알맞게 반드시 구현해야 한다.(구현하지 않으면 미완성 메서드이므로 에러를 일으킨다.)
추상 클래스는 추상 메서드만을 가질 수 있는 것이 아니라, 일반 메서드도 가질 수 있다. 대신 추상 메서드의 경우에만 자식 클래스에서 구현이 강제되고 일반 메서드는 구현이 강제되지 않는다.
인터페이스(interface)
인터페이스란?
인터페이스는 일종의 추상클래스이다. 인터페이스는 추상클래스보다 초상화 정도가 높아서 일반 메서드 또는 멤버변수를 구성원으로 가질 수 없다. 인터페이스도 추상클래스처럼 완성되지 않은 불완전한 것이기 때문에 그 자체만으로 사용되기 보다는 다른 클래스를 작성하는데 도움 줄 목적으로 작성된다.
인터페이스 작성
1
2
3
4
interface 인터페이스이름{
public static final 타입 상수이름 = 값;
public abstract 메서드이름(매개변수목록);
}
일반적인 클래스의 멤버들과 달리 인터페이스의 멤버들은 다음과 같은 제약사항이 있다.
- 모든 멤버변수는
public static final
이어야 하며, 이를 생략할 수 있다.- 모든 메서드는
public abstract
이어야 하며, 이를 생랽할 수 있다.
원래는 인터페이스의 모든 메서드는 추상메서드이어야 했지만, JDK1.8부터 인터페이스에 static 메서드와 디폴트 메서드의 추가를 허용하는 방향으로 변경되었다.
인터페이스 상속
1
interface Fightable extends Movable, Attackable {}
인터페이스는 인터페이스로부터만 상속받을 수 있으며, 클래스와는 달리 다중상속, 즉 여러 개의 인터페이스로부터 상속을 받는 것이 가능하다.
인터페이스의 구현
인터페이스도 추상클래스처럼 그 자체로는 인스턴스를 생성할 수 없으며, 추상클래스가 상속을 통해 추상 메서드를 완성하는 것처럼, 인터페이스도 자신에 정의된 추상메서드의 몸통을 만들어줘야 한다. 클래스는 확장한다는 의미의 키워드 extends
를 사용하지만 인터페이스는 구현한다는 의미로 implements
를 사용할 뿐이다.
1
2
3
class 클래스이름 implements 인터페이스이름{
// 인터페이스에 정의된 추상메서드를 구현해야 한다.
}
인터페이스를 이용한 다중상속
자바도 인터페이스를 사용하면 다중상속이 가능하기는 하지만 인터페이스로 다중상속을 구현하는 경우는 거의 없다. 만일 두개의 클래스로부터 상속을 받아야 할 상황이라면, 두 조상클래스 중에서 비중이 높은 쪽을 선택하고 다른 한쪽은 클래스 내부에 멤버로 포함시키는 방식으로 처리하거나 어느 한 쪽의 필요한 부분을 뽑아서 인터페이스로 만든 다음 구현한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class Tv{
protected boolean power;
protected int channel;
protected int volume;
public void power() {power = ! power;}
public void channelUp() {channel++;}
public void channelDown() {channel--;}
}
public class VCR{
protected int counter; // VCR 카운터
public void play(){/*테이프 재생*/}
public void stop(){/*재생을 멈춘다*/}
public void reset(){ counter = 0; }
public int getCounter() { return counter; }
public void setCounter(int c) { counter = c; }
}
public interface IVCR{
public void play();
public void stop();
public void reset();
public int getCounter();
public void setCounter(int c);
}
public class TVCR extends Tv implements IVCR{
VCR vcr = new VCR();
public void play(){
vcr.play()
}
public void stop(){
vcr.stop()
}
...
}
위 처럼 코드를 짜면 IVCR인터페이스를 구현하기 위해서 새로 메서드를 작성해야 한다는 부담이 있지만 VCR클래스의 인스턴스를 사용하면 손쉽게 다중상속을 구현할 수 있다. 또한 VCR클래스의 내용이 변경되어도 변경된 내용이 TVCR클래스에도 자동적으로 반영되는 효과를 얻는다.
사실 인터페이스를 새로 작성하지 않고도 VCR 클래스를 TVCR클래스에 포함시키는 것만으로도 충분하지만, 인터페이스를 이용하면 다형적 특성을 이용할 수 있다는 장점이 생긴다.
인터페이스를 이용한 다형성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
interface Animal {
void makeSound();
}
class Dog implements Animal {
@Override
public void makeSound() {
System.out.println("Bark");
}
}
class Cat implements Animal {
@Override
public void makeSound() {
System.out.println("Meow");
}
}
public class AnimalFactory {
// 리턴타입이 인터페이스라는 것은 메서드가 해당 인터페이스를 구현한 클래스의 인스턴스를 반환한다는 것을 의미한다.
public static Animal getAnimal(String type) {
if ("dog".equalsIgnoreCase(type)) {
return new Dog();
} else if ("cat".equalsIgnoreCase(type)) {
return new Cat();
}
return null;
}
}
public class Main {
public static void main(String[] args) {
Animal animal1 = AnimalFactory.getAnimal("dog");
animal1.makeSound(); // "Bark" 출력
Animal animal2 = AnimalFactory.getAnimal("cat");
animal2.makeSound(); // "Meow" 출력
}
}
리턴타입이 인터페이스라는 것은 메서드가 해당 인터페이스를 구현한 클래스의 인스턴스를 반환한다는 것을 의미한다.
위 코드는 Main 클래스에서 getAnimal() 메서드를 호출하여 Animal 타입의 참조 변수에 결과를 저장했다. 반환된 객체가 Dog 또는 Cat 객체인지는 중요하지 않으며, Animal 인터페이스에 정의된 makeSound() 메서드를 호출할 수 있게 됐다. 이 방식은 코드의 유연성과 확장성을 높여준다. 새로운 Animal 구현체가 추가되더라도 AnimalFactory 클래스의 getAnimal() 메서드를 수정하여 새로운 객체를 반환하도록 할 수 있으며, 기존 코드는 변경하지 않아도 된다.
리턴 타입을 인터페이스로 사용하는 것은 인터페이스의 강력한 기능 중 하나로, 다형성을 지원하여 다양한 구현체를 투명하게 사용할 수 있게 합니다.
인터페이스의 장점
- 개발시간을 단축시킬 수 있다.
- 표준화가 가능하다.
- 서로 관계없는 클래스들에게 관계를 맺어 줄 수 있다.
- 독립적인 프로그래밍이 가능하다.
어떤 부모 클래스를 상속 받는 자식 클래스들 중 다른 기능을 해야하는 경우가 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Animal {
void makeSound() {
System.out.println("Some generic animal sound");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Bark");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Meow");
}
}
Dog
와 Cat
은 Animal
클래스의 자손이다. 여기서 Dog
에만 헤엄치는 기능을 넣고싶다면 인터페이스를 활용하면된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface Swimmable {
void swim();
}
class SwimmingHelper implements Swimmable {
@Override
public void swim() {
System.out.println("Swimming using SwimmingHelper");
}
}
class Dog extends Animal implements Swimmable {
private SwimmingHelper swimmingHelper = new SwimmingHelper();
@Override
void makeSound() {
System.out.println("Bark");
}
@Override
public void swim() {
swimmingHelper.swim();
}
}
Swimmable
이라는 인터페이스를 만들고 Dog
가 이를 구현하도록 했다. 또한, Swimmable
을 구현하는 SwimmingHelper
라는 객체를 만들어 Dog
클래스 내에 포함시켰다. 이렇게 코드를 구현하면 SwimmingHelper
의 메서드를 사용하여 중복된 코드를 줄 일 수 있고 Swimmable
인터페이스를 이용해 다형적 특성을 이용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();
myDog.makeSound(); // "Bark" 출력
myCat.makeSound(); // "Meow" 출력
// Swimmable 인터페이스를 구현한 객체만 수영 기능을 사용할 수 있음
if (myDog instanceof Swimmable) {
Swimmable swimmingDog = (Swimmable) myDog;
swimmingDog.swim(); // "Swimming using SwimmingHelper" 출력
}
if (myCat instanceof Swimmable) {
Swimmable swimmingCat = (Swimmable) myCat;
swimmingCat.swim(); // 이 부분은 실행되지 않음
} else {
System.out.println("Cat cannot swim"); // "Cat cannot swim" 출력
}
}
}
인터페이스 이해
인터페이스를 이해하기 위해서는 다음의 두 가지 사항을 반드시 염두에 두고 있어야 한다.
- 클래스를 사용하는 쪽(User)과 클래스를 제공하는 쪽(Provider)이 있다.
- 메서드를 사용(호출)하는 쪽(User)에서는 사용하려는 메서드(Provider)의 선언부만 알면 된다.(내용은 몰라도 된다)
다음 코드를 살펴보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
interface Animal {
void makeSound();
void eat();
}
class Dog implements Animal {
@Override
public void makeSound() {
System.out.println("Bark");
}
@Override
public void eat() {
System.out.println("Dog is eating");
}
}
class Cat implements Animal {
@Override
public void makeSound() {
System.out.println("Meow");
}
@Override
public void eat() {
System.out.println("Cat is eating");
}
}
class Zoo {
private Animal animal;
public Zoo(Animal animal) {
this.animal = animal;
}
public void makeAnimalSound() {
animal.makeSound();
}
public void feedAnimal() {
animal.eat();
}
}
public class Main {
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();
Zoo zooWithDog = new Zoo(myDog);
Zoo zooWithCat = new Zoo(myCat);
zooWithDog.makeAnimalSound(); // "Bark" 출력
zooWithDog.feedAnimal(); // "Dog is eating" 출력
zooWithCat.makeAnimalSound(); // "Meow" 출력
zooWithCat.feedAnimal(); // "Cat is eating" 출력
}
}
이렇게 코드를 짜면 사용자 Zoo
는 Animal
을 통해 인스턴스를 사용하지만 Cat
인지 Dog
인지 알 필요가 없다. 이를 통해 코드의 유연성과 확장성을 높일 수 있다. 예를 들어, 새로운 동물 클래스가 추가되더라도 Zoo
클래스는 수정할 필요 없이 새로운 동물 객체를 받아들일 수 있다.