“동물”은 개와 고양이를 묶어 부르는 말일 뿐, 그냥 동물 한 마리는 세상에 없다. 이처럼 자식들이 함께 쓰는 것만 모아 두고, 그 자체로는 객체를 만들지 못하게 막아 둔 부모 클래스다. 자식마다 달라야 하는 동작은 이름만 적어 두고 내용은 자식이 채우게 강제할 수 있다. 자식이 내용을 빠뜨리면 실행하기 전에 걸러진다. 다만 부모는 하나만 둘 수 있다는 상속의 제약은 그대로다.
예시로 보기
개와 고양이는 둘 다 이름이 있고 숨을 쉰다. 이 공통 부분을 Animal에 모으면 두 번 쓰지 않아도 된다. 그런데 Animal을 보통 클래스로 두면 두 가지가 곤란하다. 누군가 new Animal()로 “그냥 동물”을 만들 수 있고, 소리 내는 메서드에 무엇을 써야 할지 알 수 없다. 구체적인 소리는 아직 모르기 때문이다.
abstract class Animal {
String name; // 공통 필드
Animal(String name) { // 생성자
this.name = name;
}
void breathe() { // 몸통이 있는 보통 메서드
System.out.println(name + ": 숨을 쉰다");
}
abstract void sound(); // 선언만 있고 몸통이 없다
}
class Dog extends Animal {
Dog(String name) { super(name); }
@Override
void sound() { System.out.println("멍멍"); }
}
class Cat extends Animal {
Cat(String name) { super(name); }
@Override
void sound() { System.out.println("야옹"); }
}
Animal a = new Dog("바둑이");
a.breathe();
a.sound();
a = new Cat("나비");
a.breathe();
a.sound();
바둑이: 숨을 쉰다
멍멍
나비: 숨을 쉰다
야옹
breathe는 Animal에 한 번만 썼는데 두 자식이 함께 쓴다. sound는 자식마다 다르게 채웠다.
Animal처럼 abstract를 붙여 객체를 만들지 못하게 한 클래스를 추상 클래스라고 부른다. Dog, Cat처럼 실제로 객체를 만드는 자식은 실체 클래스다. sound()처럼 몸통이 없는 메서드는 추상 메서드다.
classDiagram
class Animal {
<<abstract>>
String name
breathe()
sound()*
}
class Dog {
sound()
}
class Cat {
sound()
}
Animal <|-- Dog : extends
Animal <|-- Cat : extends
별표가 붙은 sound()가 추상 메서드다. 두 자식이 각자 채운다.
정의
추상 클래스는 실체 클래스들이 함께 가진 필드와 메서드를 뽑아 선언한 클래스다. 실체 클래스의 부모가 되어 공통 필드와 메서드를 물려준다.
선언
클래스 선언에 abstract를 붙인다. 안에는 보통 클래스처럼 필드, 생성자, 메서드를 둔다.
public abstract class Sound {
// 필드
// 생성자
// 메서드
}
new로 직접 객체를 만들 수 없다. 자식 클래스를 만들어 상속으로만 쓴다. new Animal("x")를 쓰면 Animal is abstract; cannot be instantiated라는 컴파일 오류가 난다1.
추상 메서드
자식들이 함께 가진 메서드를 부모로 뽑아 올릴 때, 선언은 같은데 하는 일이 자식마다 다르면 추상 메서드로 선언한다. 보통 메서드와 다른 점은 abstract가 붙고 몸통 { }이 없다는 것이다.
abstract 리턴타입 메서드이름(매개변수, …);
규칙은 세 가지다1.
- 추상 메서드에 몸통을 쓰면
abstract methods cannot have a body오류다. - 추상 메서드는 추상 클래스 안에만 둘 수 있다. 보통 클래스에 두면 그 클래스가
is not abstract and does not override abstract method오류를 낸다. - 실체 클래스인 자식은 부모의 추상 메서드를 모두 다시 정의해야 한다.
class Dog extends Animal { }처럼 비워 두면Dog is not abstract and does not override abstract method sound() in Animal오류다.
생성자는 있다
객체를 직접 만들 수는 없지만 생성자는 둘 수 있다. 자식 객체를 만들 때 자식 생성자가 super(...)로 부모 생성자를 먼저 부르기 때문이다(부모 생성자 호출). 위 예에서 Dog("바둑이")는 super(name)으로 Animal의 생성자를 불러 name을 채운다.
활용
- 구현: 24_java-abstract-class_impl.java
- 변수 타입을 추상 클래스로 두면 자식 객체를 모두 담을 수 있다.
Animal a = new Dog("바둑이");에서a.sound()는 Dog의sound를 실행한다(다형성). - 흔한 실수: 추상 메서드가 하나도 없으면 추상 클래스가 아니라고 생각하는 것.
abstract만 붙이면 추상 메서드가 없어도 추상 클래스이고new가 막힌다. 위의Sound틀에도 추상 메서드는 적혀 있지 않다.
연결
- 선수: 상속, 메서드 오버라이딩
- 몸통 없는 메서드만 모은 것: 인터페이스. 둘을 가르는 법은 추상 클래스와 인터페이스 비교
확인 문제
C1 Animal의 sound()를 빈 몸통 void sound() { }로 두지 않고 추상 메서드로 두면 무엇이 좋은가?
답: 자식이 sound를 채우지 않으면 컴파일 오류가 난다. 빈 몸통으로 두면 자식이 깜빡해도 컴파일이 통과하고, 실행했을 때 아무 소리도 안 나는 동물이 조용히 생긴다.
이유: 추상 메서드는 “자식이 반드시 채운다”를 컴파일러가 확인하게 하는 장치다. 실수를 실행 전에 잡는다.
C2 (가)~(마) 가운데 컴파일 오류가 나는 줄을 모두 고르고 이유를 쓰라.
abstract class Animal {
abstract void sound();
abstract void eat() { } // (가)
void breathe() { } // (나)
}
class Dog extends Animal { } // (다)
class Cat extends Animal {
void sound() { }
void eat() { }
}
class Main {
public static void main(String[] args) {
Animal a = new Animal(); // (라)
Animal b = new Cat(); // (마)
}
}
답: (가), (다), (라).
이유: (가) 추상 메서드에는 몸통을 쓸 수 없다. (다) Dog는 실체 클래스인데 부모의 추상 메서드를 채우지 않았다. (라) 추상 클래스는 new로 만들 수 없다. (나)는 추상 클래스 안의 보통 메서드라 괜찮고, (마)는 자식 객체를 부모 타입 변수에 넣는 보통 쓰임이다. 흔한 오답은 (마)를 “Animal은 추상이라 변수도 못 만든다”며 오류로 고르는 것이다1.
C3 이 코드의 출력은?
abstract class Shape {
Shape() { System.out.println("Shape 생성자"); }
abstract String name();
void describe() { System.out.println("넓이를 잴 수 있는 도형: " + name()); }
}
class Circle extends Shape {
Circle() { System.out.println("Circle 생성자"); }
@Override
String name() { return "원"; }
}
// main 안에서
Shape s = new Circle();
s.describe();
답:
Shape 생성자
Circle 생성자
넓이를 잴 수 있는 도형: 원
이유: new Circle()은 Circle 생성자의 첫 줄에 숨어 있는 super()로 Shape 생성자를 먼저 실행한다. describe는 Shape에 있지만 그 안의 name()은 실제 객체인 Circle의 것이 실행된다. 흔한 오답은 “추상 클래스는 객체를 못 만드니 생성자도 실행되지 않는다”이다1.