Chapter 7. 객체지향 프로그래밍 II
1. 상속
상속이란, 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 것이다.
자바에서 상속을 구현하기 위해서는 새로 작성하고자 하는 클래스의 이름 뒤에 상속받고자 하는 클래스의 이름을 키워드 'extends'와 함께 써 주기만 하면 된다.
▼ 상속받는 방법
class Parent { }
class Child extends Parents {
// ...
}
조상 클래스 : 부모(parent) 클래스, 상위(super) 클래스, 기반(base) 클래스
자손 클래스 : 자식(child) 클래스, 하위(sub) 클래스, 파생된(derived) 클래스
자손 클래스는 조상 클래스의 모든 멤버를 상속받기 때문에, Child 클래스는 Parent 클래스의 멤버들을 포함한다고 할 수 있다. 즉, 조상 클래스 ⊆ 자식 클래스라고 할 수 있다.
이때 생성자와 초기화 블럭은 상속되지 않으며, 멤버만 상속된다. 또한 자손 클래스의 멤버 개수는 조상 클래스보다 항상 같거나 많다.
그래서 상속을 받는다는 것은 조상 클래스를 확장(extend)한다는 의미로 해석할 수도 있으며 이것이 상속에 사용되는 키워드가 'extends'인 이유이기도 하다.
클래스 간의 형제 관계는 없으며, 부모와 자식의 관계(상속관계)만이 존재한다. 자손 클래스의 인스턴스를 생성하면 조상 클래스의 멤버와 자손 클래스의 멤버가 합쳐진 하나의 인스턴스로 생성된다.
또한 상속 이외에도 클래스를 재사용하는 방법이 있는데, 그것은 클래스 간에 '포함(Composite)' 관계를 맺어주는 것이다. 이는 한 클래스의 멤버변수로 다른 클래스 타입의 참조변수를 선언하는 것을 뜻한다.
예시로 원을 표현하기 위한 Circle이라는 클래스와 점을 표현하는 Point라는 클래스가 있을 때, Circle 클래스가 Point 클래스를 포함하는 코드로 다시 작성하면 아래와 같다.
class Circle {
int x; // 원점의 x좌표
int y; // 원점의 y좌표
int r; // 반지름(radius)
}
class Point {
int x; // x좌표
int y; // y좌표
}
// Point 클래스를 이용해 Circle 클래스를 재작성
class Circle {
Point c = new Point(); // 원점
int r;
}
하나의 거대한 클래스를 작성하는 것보다 단위별로 여러 개의 클래스를 작성한 다음, 이 단위 클래스들을 포함관계로 재사용하면 보다 간결하고 손쉽게 클래스를 작성할 수 있다. 또한 작성된 단위 클래스들은 다른 클래스를 작성하는데 재사용될 수 있을 것이다.
클래스를 작성하는데 있어 상속관계를 맺어줄 것인지 포함관계를 맺어줄 것인지는 아래와 같이 구분할 수 있다.
상속 관계 : 원(Circle)은 점(Point)이다. = Circle is a Point. (~은 ~이다, is-a)
포함 관계 : 원(Circle)은 점(Point)을 가지고 있다. = Circle has a Point. (~은 ~을 가지고 있다, has-a)
따라서 위의 Circle 클래스 같은 경우에는 포함 관계가 더 적당하다. 자세한 예제는 교재 p.318~319에 나와있다.
또한 참조변수의 출력이나 덧셈연산자를 이용한 참조변수와 문자열의 결합에는 toString()이 자동적으로 호출되어 참조변수를 문자열로 대치한 후 처리한다. (ex. println()의 호출 등)
자바에서는 C++과 다르게 하나의 조상으로부터만 상속받는 단일 상속만을 허용한다. 따라서 두 개 이상의 클래스의 사용을 원한다면 하나는 조상 클래스로, 하나는 포함관계로 하는 것이 바람직하다. 해당 관련 예제는 교재 p.324-325에 잘 나와있다.
Object 클래스는 모든 클래스 상속계층도의 최상위에 있는 조상클래스(모든 클래스의 조상)이다. 다른 클래스로부터 상속 받지 않는 모든 클래스들은 자동적으로 Object 클래스로부터 상속받게 함으로써 이것을 가능하게 한다. 따라서 자바의 모든 클래스들은 Object 클래스에 정의된 멤버들을 사용할 수 있다.
2. 오버라이딩
조상 클래스로부터 상속받은 메서드의 내용을 변경하는 것을 오버라이딩이라고 한다.
[ 오버라이딩의 조건 1 ]
자손 클래스에서 오버라이딩 하는 메서드는 조상 클래스의 메서드와
- 이름이 같아야 한다.
- 매개변수가 같아야 한다.
- 반환타입이 같아야 한다. (JDK 1.5부터 '공변 반환타입(covariant return type)'이 추가되어 반환 타입을 자손 클래스의 타입으로 변경하는 것은 가능하도록 조건이 완화되었다.)
쉽게 요약하자면 선언부가 서로 일치해야 한다는 것이다. 다만 접근 제어자(access modifier)와 예외(exception)는 제한된 조건 하에서만 다르게 변경할 수 있다.
[ 오버라이딩의 조건 2 ]
- 접근 제어자를 조상 클래스의 메서드보다 좁은 범위로 변경할 수 없다.
만약 조상 클래스에 정의된 메서드의 접근 제어자가 protected라면, 이를 오버라이딩하는 자손 클래스의 메서드는 접근 제어자가 protected나 public이어야 한다.
접근 제어자의 범위 : public > protected > (default) > private - 예외는 조상 클래스의 메서드보다 많이 선언할 수 없다.
예를 들어 조상 클래스에는 IOException, SQLException을, 자손 클래스에는 IOException을 선언했다면 바르게 오버라이딩 했지만 자손 클래스에 Exceptiond을 선언할 경우 오류가 발생한다. 개수 자체는 적지만, Exception은 모든 예외의 최고 조상이므로 가장 많은 개수의 예외를 던질 수 있도록 선언한 것이다. - 인스턴스 메서드를 static 메서드로 또는 그 반대로 변경할 수 없다.
단, 만약 조상 클래스에서 정의된 static 메서드를 자손 클래스에서 똑같은 이름의 static 메서드로 정의한다면 이는 각 클래스에 별개의 static 메서드를 정의한 것일뿐, 오버라이딩이 아니다. 이 경우 호출할 때에도 참조변수의 이름보다는 클래스이름으로 호출하는 것이 바람직하다.
오버로딩(overloading) : 기존에 없는 새로운 메서드를 정의하는 것(new), 매개변수의 갯수가 달라야 함, 같은 클래스 내부
오버라이딩(overriding) : 상속받은 메서드의 내용을 변경하는 것(change, modify), 매개변수가 같아야 함, 다른 클래스
super는 자손 클래스에서 조상 클래스로부터 상속받은 멤버를 참조하는데 사용되는 참조 변수이다. 상속받은 멤버와 자신의 멤버와 이름이 같을 때에는 super를 붙여서 구별할 수 있다. super 역시 static 메서드에서는 사용할 수 없고, 인스턴스 메서드에서만 사용할 수 있다.
class SuperText2 {
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 = " + x);
System.out.println("this.x = " + this.x);
System.out.println("super.x = " + super.x);
}
}
위 코드와 같이 같은 이름의 멤버 변수가 조상 클래스에도 있고 자손 클래스에도 있을 경우, super.x와 this.x는 서로 다른 값을 참조하게 된다.
변수 뿐만 아니라 조상 클래스의 메서드를 자손 클래스에서 오버라이딩한 경우에 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을 사용해 조상클래스의 메서드를 포함시키는 것이 좋다. 이 경우 나중에 조상 클래스의 메서드가 변경되더라도 변경된 내용이 자손클래스의 메서드에 자동적으로 반영되기 때문이다.
this()와 마찬가지로 super() 역시 생성자이다. this()는 같은 클래스의 다른 생성자를 호출하는 데 사용되지만, super()는 조상 클래스의 생성자를 호출하는데 사용된다.
자손 클래스가 인스턴스를 생성하면, 자손의 멤버와 조상의 멤버가 모두 합쳐진 하나의 인스턴스가 생성된다. 이때 조상 클래스 멤버의 초기화 작업이 수행되어야 하기 때문에 자손 클래스의 생성자에서 조상 클래스의 생성자가 호출되어야 한다. 이와 같은 조상 클래스의 생성자 호출은 최고 조상 클래스의 생성자인 Object()까지 가서야 끝이 난다.
따라서 Object 클래스를 제외한 모든 클래스의 생성자 첫 줄에 this() 또는 super()를 호출해야 한다. 그렇지 않으면 컴파일러가 자동으로 'super();'을 생성자의 첫 줄에 삽입한다. 이때 super()는 조상 클래스의 기본 생성자를 의미한다.
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) {
// 이 경우 이곳에 super();가 들어간다.
// super()는 Point의 조상 클래스인 Object 클래스의 기본 생성자인 Object()를 의미한다.
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) {
// this.x = x;
// this.y = y;
// 위의 경우 조상 클래스의 생성자인 Point()를 찾을 수 없다는 컴파일 에러가 발생한다.
// super();가 this.x = x;의 윗줄에 포함된 것과 같은 상황이기 때문이다.
// 따라서 아래와 같이 수정해야 한다.
super(x, y); // 조상 클래스의 생성자 Point(int x, int y)를 호출한다.
this.z = z;
}
String getLocation() {
return super.getLocation() + ", z : " + z;
}
}
위 코드와 같이, 조상 클래스의 멤버변수는 이처럼 조상 생성자에 의해 초기화되도록 해야한다.
3. package와 import
패키지(package)란, 클래스의 묶음이다. 패키지에는 클래스 또는 인터페이스를 포함시킬 수 있으며, 서로 관련된 클래스들끼리 그룹 단위로 묶어 놓음으로써 클래스를 효율적으로 관리할 수 있다.
같은 이름의 클래스 일지라도 서로 다른 패키지에 존재하는 것이 가능하므로, 자신만의 패키지 체계를 유지함으로써 다른 개발자가 개발한 클래스 라이브러리의 클래스와 이름이 충돌하는 것을 피할 수 있다.
클래스의 실제 이름(full name)은 패키지명을 포함한 것이다. 따라서 같은 이름의 클래스라도 패키지명으로 구별이 가능하다.
ex) String 클래스의 실제 이름 = java.lang.String : java의 서브 디렉토리인 lang 패키지에 속한 String 클래스
* 클래스 파일들을 압축한 것이 jar파일(*.jar)이며, jar파일은 'jar.exe'외에도 알집이나 winzip으로 압축을 풀 수 있다.
클래스가 물리적으로 하나의 클래스파일(.class)인 것과 같이 패키지는 물리적으로 하나의 디렉토리다. 그래서 어떤 패키지에 속한 클래스는 해당 디렉토리에 존재하는 클래스파일(.class)이어야 한다.
[ 패키지 선언의 특징 ]
- 하나의 소스파일에는 첫 번째 문장으로 단 한 번의 패키지 선언만을 허용한다.
- 모든 클래스는 반드시 하나의 패키지에 속해야 한다.
- 패키지는 점(.)을 구분자로 하여 계층구조로 구성할 수 있다.
- 패키지는 물리적으로 클래스 파일(.class)을 포함하는 하나의 디렉토리이다.
▼ 패키지 선언
package 패키지명;
패키지 선언은 클래스나 인터페이스의 소스파일(.java)의 맨 위에 위와 같이 한 줄만 적어주면 된다. 패키지 선언문은 반드시 주석과 공백을 제외한 첫 번째 문장이어야 하며, 하나의 소스파일에 단 한 번만 선언될 수 있다. 해당 소스파일에 포함된 모든 클래스나 인터페이스는 선언된 패키지에 속하게 된다.
패키지명은 대소문자를 모두 허용하지만, 클래스명과 쉽게 구분하기 위해서 소문자로 하는 것을 원칙으로 한다.
자바에서는 기본적으로 '이름없는 패키지(unnamed package)'를 제공한다. 즉, 패키지를 지정하지 않은 모든 클래스는 같은 패키지에 속하는 셈이다.
예제 코드를 작성한 뒤 아래 코드와 같이 '-d' 옵션을 추가하여 컴파일을 할 경우, 소스파일에 지정된 경로를 통해 패키지의 위치를 찾아서 클래스파일을 생성하게 된다. 만약 지정된 패키지와 일치하는 디렉토리가 존재하지 않는다면 자동적으로 생성한다.
이때 -d의 옵션의 구조는 -d (.class 파일 및 패키지를 생성할 디렉토리 위치) (.java 파일의 위치)다.
아래 사진에서는 .java 파일과 .class 파일을 한 폴더에 같이 넣기 위해, -d 옵션의 첫 번째 인자로 \sample\sample0의 디렉토리가 시작하는 \JavaStudy\src 폴더를 디렉토리(루트 디렉토리)로 입력해주었다. 결과는 아래 사진과 같이 컴파일러가 패키지의 위치를 찾아, sample.sample0 패키지에 .class를 넣은 것을 볼 수 있다.
InteliJ IDEA 환경에서는 package 명령어에서 해당 패키지가 존재하지 않을 경우, 빨간 줄이 뜨며 디버그가 불가능해지기 때문에 미리 패키지를 사전에 생성해야 했다.
이후 교재에는 클래스패스(classpath)를 설정하는 방법에 대해 설명하지만(교재 p.338-339) inteliJ IDEA의 터미널에서는 터미널에서 java를 실행했을 때 잘 작동했다.
소스 코드를 작성할 때, 다른 패키지의 클래스를 이용하려면 패키지명이 포함된 클래스 이름을 사용해야 한다. 하지만, 매번 패키지명을 붙여서 작성하기는 불편하다.
이를 위해 클래스의 코드를 작성하기 전에 import문으로 사용하고자 하는 클래스의 패키지를 미리 명시해주면 소스코드에 사용되는 클래스이름에서 패키지명은 생략할 수 있다.
[ import문의 선언 ]
일반적인 소스파일(*.java)의 구성은 다음의 순서로 되어 있다.
① package문
② import문
③ 클래스 선언
import 패키지명.클래스명;
import 패키지명.*;
import문을 불러오는 것은 실행 시 성능상의 차이는 전혀 없다. 또한 *를 사용하여 호출가능한 것은 클래스이며, 패키지 호출은 불가능하다.
import java.util.*; // java.util 패키지의 class 호출(가능)
import java.text.*; // java.text 패키지의 class 호출(가능)
import java.*; // java의 하위 패키지 호출 불가능
또한 모든 소스파일에는 아래와 같은 import문이 묵시적으로 선언되어 있다.
import java.lang.*;
static import문을 사용하면 static 멤버를 호출할 때 클래스 이름을 생략할 수 있다. 예를 들어 Math 인스턴스를 생성하지 않아도 사용 가능한 random() 메서드 등이 있다.
import static java.lang.Math.random; // Math.random()만. 괄호 안 붙임
import static java.lang.Math.*; // Math 클래스의 모든 static 메서드
import static java.lang.System.out; // System.out을 out만으로 참조 가능
class StaticImportEx1 {
public static void main(String[] args_) {
// System.out.println(Math.random());
out.println(random());
// System.out.println("Math.PI : " + Math.PI);
out.println("Math.PI : " + PI);
}
}
4. 제어자(modifier)
접근 제어자 : public, protected, default, private
그 외 : static, final, abstract, native, transient, synchronized, volatile, strictfp
static은 '클래스의' 또는 '공통적인'의 의미를 가지고 있다. 클래스변수(static 멤버변수)는 인스턴스에 관계 없이 같은 값을 갖는다. 또한, static 초기화 블럭은 클래스가 메모리에 로드될 때 단 한 번만 수행되며, 주로 클래스변수(static 멤버변수)를 초기화하는데 주로 사용된다.
static이 사용될 수 있는 곳 : 멤버변수, 메서드, 초기화 블럭
제어자 | 대상 | 의미 |
static | 멤버변수 | - 모든 인스턴스에 공통적으로 사용되는 클래스변수가 된다. - 클래스변수는 인스턴스를 생성하지 않고도 사용 가능하다. - 클래스가 메모리에 로드될 때 생성된다. |
메서드 | - 인스턴스를 생성하지 않고도 호출이 가능한 static 메서드가 된다. - static 메서드 내에서는 인스턴스 멤버들을 직접 사용할 수 없다. |
final은 '마지막의' 또는 '변경될 수 없는'의 의미를 가지고 있으며, 거의 모든 대상에 사용될 수 있다. 변수에 사용되면 값을 변경할 수 없는 상수가 되며, 메서드에 사용하면 오버라이딩을 할 수 없게 되고, 클래스에 사용하면 자신을 확장하는 자손 클래스를 정의하지 못하게 된다.
대표적인 final 클래스로는 String과 Math가 있다.
final이 사용될 수 있는 곳 : 클래스, 메서드, 멤버변수, 지역변수
제어자 | 대상 | 의미 |
final | 클래스 | 변경될 수 없는 클래스, 확장될 수 없는 클래스가 된다. 그래서 final로 지정된 클래스는 다른 클래스의 조상이 될 수 없다. |
메서드 | 변경될 수 없는 메서드, final로 지정된 메서드는 오버라이딩을 통해 재정의 될 수 없다. | |
멤버변수 | 변수 앞에 final이 붙으면, 값을 변경할 수 없는 상수가 된다. | |
지역변수 |
final이 붙은 변수는 상수이므로 일반적으로 선언과 초기화를 동시에 하지만, 인스턴스 변수의 경우 생성자에서 초기화 되도록 할 수 있다. 클래스 내부에 매개변수를 갖는 생성자를 선언하여, 인스턴스를 생성할 때 final이 붙은 멤버변수를 초기화하는데 필요한 값을 생성자의 매개변수로부터 제공받는 것이다. 이 경우 각 인스턴스마다 final이 붙은 멤버변수가 다른 값을 갖도록 하는 것이 가능하다.
이 경우 한 번 생성자에서 초기화된 final 멤버변수를 클래스 내에서 값을 다시 변경하는 것은 불가능하다.
abstract는 '미완성'의 의미를 가지고 있다. 메서드의 선언부만 작성하고 실제 수행내용은 구현하지 않은 추상 메서드를 선언하는데 사용된다. 클래스에 사용되어 클래스 내에 추상메서드가 존재한다는 것을 쉽게 알 수 있게 한다.
abstract가 사용될 수 있는 곳 : 클래스, 메서드
제어자 | 대상 | 의미 |
abstract | 클래스 | 클래스 내에 추상 메서드가 선언되어 있음을 의미한다. |
메서드 | 선언부만 작성하고 구현부는 작성하지 않은 추상 메서드임을 알린다. |
추상 클래스는 아직 완성되지 않은 메서드가 존재하는 '미완성 설계도'이므로 인스턴스를 생성할 수 없다.
그러나 때로는 추상 메서드가 없는데도 클래스에 abstract를 붙여 추상 클래스를 만드는 경우가 있는데, 아무런 내용이 없는 메서드들만 정의되어 있는 클래스(ex. java.awt.event.WindowAdapter)가 그렇다. 이런 클래스는 다른 클래스가 이 클래스를 상속받아 일부의 원하는 메서드만 오버라이딩 할 수 있다.
접근 제어자는 멤버 또는 클래스에 사용되어, 해당하는 멤버 또는 클래스를 외부에서 접근하지 못하도록 제한하는 역할을 한다.
아래 표에는 default가 있지만, 실제로 접근 제어자가 default임을 알리기 위해 해당 제어자를 사용하지는 않는다. 클래스나 멤버 변수, 메서드, 생성자에 접근 제어자가 지정되어 있지 않다면, 접근 제어자가 default임을 뜻한다.
접근 제어자가 사용될 수 있는 곳 : 클래스, 멤버변수, 메서드, 생성자
private : 같은 클래스 내에서만 접근이 가능하다.
default : 같은 패키지 내에서만 접근이 가능하다.
protected : 같은 패키지 내에서, 그리고 다른 패키지의 자손 클래스에서 접근이 가능하다.
public : 접근 제한이 전혀 없다.
제어자 | 같은 클래스 | 같은 패키지 | 자손 클래스 | 전체 |
public | O | O | O | O |
protected | O | O | O | |
(default) | O | O | ||
private | O |
접근 범위가 넓은 쪽에서 좁은 쪽의 순으로 왼쪽부터 나열하면 다음과 같다.
public > protected > (default) > private
▼ 대상에 따라 사용할 수 있는 접근 제어자
대상 | 사용 가능한 접근 제어자 |
클래스 | public, (default) |
메서드 | public, protected, (default), private |
멤버변수 | |
지역변수 | 없음 |
[ 접근 제어자를 사용하는 이유 ]
- 외부로부터 데이터를 보호하기 위해서
: 데이터가 유효한 값을 유지하도록, 외부에서 함부로 변경하지 못하도록 데이터 감추기(data hiding) 또는 캡슐화(encapsulation)를 한다. - 외부에는 불필요한, 내부적으로만 사용되는 부분을 감추기 위해서
: 외부에서 접근할 필요가 없는 멤버들을 private으로 지정하여 외부에 노출시키지 않음으로써 복잡성 감소. 마찬가지로 캡슐화다.
만약 메서드 하나를 변경한다고 했을 때, 접근 제어자가 public이라면 메서드를 변경한 후에 오류가 없는지 테스트해야 하는 범위가 넓다. 그러나 접근 제어자가 default라면 패키지 내부만 확인해보면 되고, private이면 클래스 하나만 살펴보면 된다.
외부에서 객체를 생성해 멤버 변수에 직접 접근하는 것보단, 아래와 같이 메서드를 작성하여 간접적으로 접근하는 것이 낫다. 만약 상속을 통해 확장될 것이 예상되는 클래스라면 멤버에 접근 제한을 주되 자손 클래스에서 접근하는 것이 가능하도록 protected를 사용하면 된다. private이 붙은 멤버는 자손 클래스에서도 접근이 불가능하다.
class Time {
private int hour, minute, second;
Time(int hour, int minute, int second) {
setHour(hour);
setMinute(minute);
setSecond(second);
}
public int getHour() { return hour; }
public void setHour(int hour) {
if (hour < 0 || hour > 23) { return; }
this.hour = hour;
}
public int getMinute() { return minute; }
public ivoid setMinute(int minute) {
if (minute < 0 || minute > 59) { return; }
this.minute = minute;
}
public int getSecond() { return second; }
public ivoid setSecond(int second) {
if (second < 0 || second > 59) { return; }
this.second = second;
}
public String toString() {
return hour + " : " + minute + " : " + second;
}
}
public class TimeTest {
public static void main(String[] args) {
Time t = new Time(12, 35, 30);
System.out.println(t);
// 아래 코드의 경우 hour 멤버변수가 private이므로 접근 불가능 오류가 발생한다.
// t.hour = 13;
t.setHour(t.getHour() + 1); // 현재보다 1시간 후로 변경한다.
System.out.println(t);
}
}
위 코드와 같이 멤버변수의 값을 읽는 메서드의 이름을 'get멤버변수이름'으로 하고, '겟터(getter)'라고 부른다. 멤버변수의 값을 변경하는 메서드의 이름은 'set멤버변수이름'으로 하고, '셋터(setter)'라고 부른다.
생성자에 접근 제어자를 사용함으로써 인스턴스의 생성을 제한할 수 있다.
만약 생성자의 접근 제어자를 private으로 지정하면, 외부에서 생성자에 접근할 수 없으므로 인스턴스를 생성할 수 없게 된다. 그래도 클래스 내부에서는 인스턴스를 생성할 수 있다. 대신 인스턴스를 생성해서 반환해주는 public 메서드를 제공함으로써 외부에서 이 클래스의 인스턴스를 사용하도록 할 수 있다. 이 메서드는 public인 동시에 static이어야 한다. 이러한 제한을 통해 사용할 수 있는 인스턴스의 개수를 제한할 수 있다.
또한 생성자가 private인 경우, 다른 클래스의 조상이 될 수 없다. (자손 클래스가 인스턴스를 생성할 때 조상 클래스의 생성자를 호출해야 하는데, 호출 불가능)
그래서 클래스 앞에 final을 더 추가하여 상속할 수 없는 클래스라는 것을 알리는 것이 좋다.
(ex. Math 클래스는 몇 개의 상수와 static 메서드만으로 구성되어 있기 때문에 인스턴스를 생성할 필요가 없다. 따라서 외부로부터의 불필요한 접근을 막기 위해 생성자의 접근 제어자가 private이다.)
아래는 생성자에 접근 제어자를 사용하는 코드의 예시다.
final class Singleton {
private static Singleton s = new Singleton();
private Singleton() { // 생성자에 private 접근 제어자 작성
// ...
}
public static Singleton getInstance() {
if (s == null)
s = new Singleton();
return s;
}
}
class SingletonTest {
public static void main(String[] args) {
// 아래와 같이 코드를 작성할 경우, 생성자에 접근 불가이므로 오류가 발생한다.
// Singleton s = new Singleton();
Singleton s = Singleton.getInstance();
}
}
▼ 대상에 따라 사용할 수 있는 제어자
대상 | 사용가능한 제어자 |
클래스 | public, (default), final, abstract |
메서드 | 모든 접근 제어자, final, abstract, static |
멤버변수 | 모든 접근 제어자, final, static |
지역변수 | final |
[ 제어자를 조합해서 사용 시 주의사항 ]
- 메서드에 static과 abstract를 함께 사용할 수 없다.
static 메서드는 몸통이 있는 메서드에만 사용할 수 있기 때문이다. - 클래스에 abstract와 final을 동시에 사용할 수 없다.
클래스에 사용되는 final은 클래스를 확장할 수 없다는 의미이고 abstract는 상속을 통해서 완성되어야 한다는 의미이므로 서로 모순되기 때문이다. - abstract 메서드의 접근 제어자가 private일 수 없다.
abstract 메서드는 자손클래스에서 구현해주어야 하는데 접근 제어자가 private이면, 자손 클래스에서 접근할 수 없기 때문이다. - 메서드에 private과 final을 같이 사용할 필요는 없다.
접근 제어자가 private인 메서드는 오버라이딩 될 수 없기 때문이다. 이 둘 중 하나만 사용해도 의미가 충분하다.
5. 다형성(polymorphism)
객체지향개념에서 다형성이란 '여러 가지 형태를 가질 수 있는 능력'을 의미하며, 자바에서는 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있는 것이다. 이를 좀 더 구체적으로 말하자면, 조상클래스 타입의 참조변수로 자손클래스의 인스턴스를 참조할 수 있도록 하였다는 것이다.
class TV {
boolean power;
int channel;
void power() { power != power; }
void channelUp() { ++channel; }
void channelDown() { --channel; }
}
class CaptionTV extends TV {
String text;
void caption() { ... }
}
// 일반적인 인스턴스 생성
TV t = new TV();
CaptionTV c = new CaptionTV();
// 다형성
TV t = new CaptionTV();
지금까지 생성된 인스턴스를 다루기 위해서, 인스턴스의 타입과 일치하는 타입의 참조변수만을 사용했다. 그러나 두 클래스가 상속 관계에 있다면, 조상 클래스 타입의 참조 변수로 자손 클래스의 인스턴스를 참조하도록 하는 것도 가능하다. 그러나 이 경우, 자식 클래스의 모든 멤버를 사용할 수 있는 것은 아니다.
조상 타입의 참조변수로는 자식 클래스의 인스턴스 중에서 조상 클래스의 멤버들(상속받은 멤버 포함)만 사용할 수 있다. 즉, 조상 타입의 참조변수로 참조된 인스턴스 역시 자식 타입의 참조변수로 참조된 인스턴스와 동일하지만, 참조변수의 타입에 따라 사용할 수 있는 멤버의 개수가 달라진다.
그러나 반대로, 자손 클래스 타입의 참조변수로 조상 타입의 인스턴스를 참조하는 것은 불가능하다. 이는 참조 변수가 실제 인스턴스보다 사용할 수 있는 멤버 개수가 더 많기 때문이다. 즉, 존재하지 않는 멤버를 사용하고자 할 가능성이 있으므로 허용하지 않는 것이다. 참조변수가 사용할 수 있는 멤버의 개수는 인스턴스의 멤버 개수보다 같거나 적어야 한다.
기본형 변수와 같이 참조변수도 형변환이 가능하다. 단, 서로 상속관계에 있는 클래스 사이에서만 가능하다.
자손타입 → 조상타입 (Up-casting) : 형변환 생략가능
자손타입 ← 조상타입 (Down-casting) : 형변환 생략불가
class Car {
String color;
int door;
void drive() {
System.out.println("drive, Brrrr~");
}
void stop() {
System.out.println("stop!!!");
}
}
class FireEngine extends Car {
void water() {
System.out.println("water!!!");
}
}
class Ambulance extends Car {
void siren() {
System.out.println("siren~~~");
}
}
class CastingTest {
public static void main(String args[]) {
FireEngine f;
Ambulance a;
a = (Ambulance)f; // 에러. 상속관계가 아닌 클래스간의 형변환 불가
f = (FireEngine)a; // 에러. 상속관계가 아닌 클래스간의 형변환 불가
Car car = null;
FireEngine fe = new FireEngine();
FireEngine fe2 = null;
fe.water();
car = fe; // car = (Car)fe;에서 형변환 생략됨
// 아래 코드는 컴파일 에러 발생. Car 타입의 참조변수로는 water()을 호출할 수 없음
// car.water();
fe = (FireEngine)car; // 형변환 생략 불가. 다운 캐스팅
fe2 = (FireEngine)car;
fe2.water();
}
}
Car 타입의 참조변수 c를 Car 타입의 조상인 Object 타입의 참조변수로 형변환하는 것은 참조변수가 다룰 수 있는 멤버의 개수가 실제 인스턴스가 갖고 있는 멤버의 개수보다 적을 것이 분명하므로 문제가 되지 않는다. 그래서 형변환을 생략할 수 있도록 한 것이다.
하지만 Car 타입의 참조변수 c를 자손인 FireEngine 타입으로 변환하는 것은 참조변수가 다룰 수 있는 멤버의 개수를 늘리는 것이므로, 실제 인스턴스의 멤버 개수보다 참조변수가 다룰 수 있는 멤버의 개수가 더 많아지므로 문제가 발생할 가능성이 있다.
따라서 자손 타입으로의 형변환은 생략할 수 없으며, 형변환을 수행하기 전에 instanceof 연산자를 사용해서 참조변수가 참조하고 있는 실제 인스턴스의 타입을 확인하는 것이 안전하다.
class CastingTest2 {
public static void main(String args[]) {
Car car = new Car();
Car car2 = null;
FireEngine fe = null;
car.drive();
fe = (FireEngine)car; //컴파일은 괜찮지만 실행 시 오류 발생
fe.drive();
car2 = fe;
car2.drive();
}
}
위 코드를 실행하면 8번째 줄에서 ClassCastException 오류가 발생하는 것을 확인할 수 있다. 이것은 참조변수 car가 참조하고 있는 인스턴스가 조상 클래스인 Car타입의 인스턴스이기 때문이다. 즉, 조상 타입의 인스턴스를 자손타입의 참조변수로 참조하는 것과 동일하게 되어 오류가 발생한다.
해당 오류를 해결하기 위해서는 'Car car = new Car();를 'Car car = new FireEngine();'으로 수정하면 에러가 발생하지 않는다.
서로 상속관계에 있는 타입간의 형변환은 양방향으로 자유롭게 수행될 수 있으나, 참조변수가 가리키는 인스턴스의 자손타입으로의 형변환은 허용되지 않는다.
그래서 참조변수가 가리키는 인스턴스의 타입이 무엇인지 확인하는 것이 중요하다.
참조변수가 참조하고 있는 인스턴스의 실제 타입을 알아보기 위해 instanceof 연산자를 사용한다. 주로 조건문에 사용되며, instanceof의 왼쪽에는 참조변수를, 오른쪽에는 타입(클래스명)이 피연산자로 위치한다. 그리고 연산의 결과로 boolean 값인 true와 false 중의 하나를 반환한다.
어떤 타입에 대한 instanceof 연산의 결과가 true라는 것은 검사한 타입으로 형변환이 가능하다는 것을 뜻한다.
만약 FireEngine 타입 변수 fe를 가지고 fe instanceof FireEngine, fe instance of Car, fe instanceof Object를 실행할 경우 값은 전부 true가 나온다. FireEngine 클래스는 Object 클래스와 Car 클래스의 자손 클래스이기 때문이다. 즉, 자손 클래스 → 조상 클래스로의 형변환(Up-casting)이 가능하다.
void doWork(Car c) {
if (c instanceof FireEngine) {
FireEngine fe = (FireEngine)c;
fe.water();
...
} else if (c instanceof Ambulance) {
Ambulance a = (Ambulance)c;
a.siren();
...
}
...
}
위의 코드는 Car 타입의 참조변수 c를 매개변수로 하는 메서드이다. 이 메서드가 호출될 때, 매개변수로 Car클래스 또는 그 자손 클래스의 인스턴스를 넘겨받겠지만 메서드 내에서는 정확히 어떤 인스턴스인지 알 수 없다. 그래서 instanceof 연산자를 이용해 참조변수 c가 가리키고 있는 인스턴스의 타입을 체크하고, 적절히 형변환한 다음에 작업을 해야한다.
조상 타입의 참조변수로 자손 타입의 인스턴스를 참조할 수 있기 때문에, 참조변수의 타입과 인스턴스의 타입이 항상 일치하지 않는다는 것을 배웠다. 조상타입의 참조변수로는 실제 인스턴스의 멤버들을 모두 사용할 수 없기 때문에, 실제 인스턴스와 같은 타입의 참조변수로 형변환을 해야만 인스턴스의 모든 멤버들을 사용할 수 있다.
'참조변수.getClass().getName()'은 참조변수가 가리키고 있는 인스턴스의 클래스 이름을 문자열(String)으로 반환한다.
멤버변수가 조상 클래스와 자손 클래스에 중복으로 정의된 경우, 조상 타입의 참조변수를 사용했을 때는 조상 클래스에 선언된 멤버변수가, 자손 타입의 참조변수를 사용했을 때는 자손 클래스에 선언된 멤버변수가 사용된다.
그러나 메서드의 경우에는 참조변수의 타입에 관계 없이 항상 실제 인스턴스의 타입에 정의된 메서드가 호출된다.
참조변수의 다형적인 특징은 메서드의 매개변수에도 적용된다. 아래는 예제 코드로, Product라는 조상 클래스에게 Tv, Computer, Audio라는 자식 클래스가 있고 이와 별개로 Buyer라는 클래스가 있을 때, Buyer 클래스에 물건을 구매하는 메서드를 작성한다면 모든 클래스에 대해 일일히 참조변수 타입을 변경하여 함수를 작성하는 것보다는 다형성을 적용하여 조상 타입의 참조변수를 매개변수로 받아 작성하는 것이 낫다.
class Product {
int price; // 제품의 가격
int bonusPoint; // 제품구매 시 제공하는 보너스점수
Product(int price) {
this.price = price;
bonusPoint = (int)(price/10.0); // 보너스점수는 제품가격의 10%
}
Product() {} // 기본 생성자
}
class Tv extends Product {
Tv() {
// 조상 클래스의 생성자 Product(int price)를 호출한다.
super(100);
}
// Object 클래스의 toString()을 오버라이딩한다.
public String toString() { return "Tv"; }
}
class Computer extends Product {
Computer() { super(200); }
public String toString() { return "Computer"; }
}
class Audio extends Product {
Audio() { super(50); }
public String toString() { return "Audio"; }
class Buyer { // 고객, 물건을 사는 사람
int money = 1000; // 소유 금액
int bonusPoint = 0; // 보너스점수
Product[] item = new Product[10]; // 구입한 제품을 저장하기 위한 배열
int i = 0; // Product배열 item에 사용될 index
void buy(Product p) {
if (money < p.price) {
System.out.println("잔액이 부족하여 물건을 살 수 없습니다.");
return;
}
money -= p.price;
bonusPoint += p.bonusPoint;
item[i++] = p;
System.out.println(p + "을/를 구입하셨습니다.");
}
void summary() { // 구매한 물품에 대한 정보를 요약해서 보여준다.
int sum = 0; // 구입한 물품의 가격합계
String itemList = ""; // 구입한 물품목록
// 반복문을 이용해서 구입한 물품의 총 가격과 목록을 만든다.
for (int i = 0; i < item.length; i++) {
if (item[i] == null) break;
sum += item[i].price;
itemList += item[i] + ", ";
// 만약 구성품의 마지막에 출력되는 콤마(,)가 거슬린다면 아래와 같이 코드를 변경
// itemList += (i == 0) ? "" + item[i] : ", " + item[i];
}
System.out.println("구입하신 물품의 총금액은 " + sum + "만원입니다.");
System.out.println("구입하신 제품은 " + itemList + "입니다.");
}
}
class PolyArgumentTest {
public static void main(String args[]) {
Buyer b = new Buyer();
b.buy(new Tv());
// 위 코드는 아래와 동일하다.
// Tv t = new Tv();
// b.buy(t);
b.buy(new Computer());
System.out.println("현재 남은 돈은 " + b.money + "만원입니다.");
System.out.println("현재 보너스점수는 " + b.bonusPoint + "점입니다.");
b.buy(new Audio());
b.summary();
}
}
조상 타입의 참조변수 배열을 사용하면, 공통의 조상을 가진 서로 다른 종류의 객체를 배열로 묶어서 다룰 수 있다. 위 코드에서는 Buyer 클래스에서 조상 타입의 참조변수 배열인 Product item을 사용하고 있는 것을 볼 수 있다.
만약 해당 배열의 길이를 10개 이상으로 유동적으로 늘리고 싶다면 Vector 클래스를 사용하면 된다. 해당 클래스는 동적으로 크기가 관리되는 객체배열이다.
▼ Vector 클래스의 주요 메서드
메서드 / 생성자 | 설명 |
Vector() | 10개의 객체를 저장할 수 있는 Vector 인스턴스를 생성한다. 10개 이상의 인스턴스가 저장되면, 자동적으로 크기가 증가된다. |
boolean add(Object o) | Vector에 객체를 추가한다. 추가에 성공하면 결과값으로 true, 실패하면 false를 반환한다. |
boolean remove(Object o) | Vector에 저장되어 있는 객체를 제거한다. 제거에 성공하면 true, 실패하면 false를 반환한다. |
boolean isEmpty() | Vector가 비어있는지 검사한다. 비어있으면 true, 비어있지 않으면 false를 반환한다. |
Object get(int index) | 지정된 위치(index)의 객체를 반환한다. 반환타입이 Object타입이므로 적절한 타입으로의 형변환이 필요하다. |
int size() | Vector에 저장된 객체의 개수를 반환한다. |
Vector을 사용하여 코드를 작성할 경우, Buyer 클래스는 아래와 같이 변경할 수 있다. 위 함수 중 add, remove, isEmpty, get, size등이 사용된 것을 확인할 수 있다.
class Buyer {
int money = 1000;
int bonusPoint = 0;
Vector item = new Vector(); // 구입한 제품을 저장하는데 사용될 Vector 개체
void buy(Product p) {
if (money < p.price) {
System.out.println("잔액이 부족하여 물건을 살 수 없습니다.");
return;
}
money -= p.price;
bonusPoint += p.bonusPoint;
item.add(p);
System.out.println(p + "을/를 구입하셨습니다.");
}
void refund(Product p) { // 구입한 제품을 환불한다.
if (item.remove(p)) { // 제품을 Vector에서 제거한다.
money += p.price;
bonusPoint -= p.bonusPoint;
System.out.println(p + "을/를 반품하셨습니다.");
} else { // 제거에 실패한 경우
System.out.println("구입하신 제품 중 해당 제품이 없습니다.");
}
}
void summary() {
int sum = 0;
String itemList = "";
if (item.isEmpty()) { // Vector가 비어있는지 확인한다.
System.out.println("구입하신 제품이 없습니다.");
return;
}
for (int i = 0; i < item.size(); i++) {
Product p = (Product)item.get(i) // Vector의 i번째 객체 반환 후 형변환
sum += p.price;
itemList += (i == 0) ? "" + p : ", " + p;
}
System.out.println("구입하신 물품의 총금액은 " + sum + "만원입니다.");
System.out.println("구입하신 제품은 " + itemList + "입니다.");
}
}
6. 추상 클래스(abstract class)
추상 클래스는 상속을 통해서 자손클래스에 의해서만 완성될 수 있는 클래스로, 미완성 메서드(추상 메서드)를 포함하고 있다는 의미다. 키워드 'abstract'를 붙이기만 하면 추상 클래스로 선언된다.
▼ 추상 클래스 선언
abstract class 클래스이름 {
...
}
추상 클래스는 추상 메서드를 포함하고 있다는 것을 제외하고는 일반 클래스와 전혀 다르지 않다. 추상 클래스에도 생성자가 있으며, 멤버변수와 메서드도 가질 수 있다.
추상 메서드는 선언부만 작성된 메서드로 실제 수행될 내용이 작성되지 않았기 때문에 미완성 메서드다. 이는 메서드의 내용이 상속받는 클래스에 따라 달라질 수 있기 때문에 조상 클래스에서는 선언부만을 작성하고, 주석을 덧붙여 어떤 기능을 수행할 목적으로 작성되었는지 알려주고, 실제 내용은 상속받는 클래스에서 구현하도록 비워놓는 것이다.
추상메서드 역시 키워드 'abstract'를 앞에 붙여주고, 구현부가 없으므로 괄호{ }대신 문장의 끝을 알리는 ';'를 적어준다.
▼ 추상 메서드 선언
/* 주석을 통해 어떤 기능을 수행할 목적으로 작성하였는지 설명한다. */
abstract 리턴타입 메서드이름();
추상 클래스로부터 상속받는 자손클래스는 오버라이딩을 통해 조상인 추상 클래스의 추상 메서드를 모두 구현해주어야 한다. 만약 하나라도 구현하지 않는다면, 자손 클래스 역시 추상 클래스로 지정해주어야 한다.
추상화 : 클래스간의 공통점을 찾아내서 공통의 조상을 만드는 작업
구체화 : 상속을 통해 클래스를 구현, 확장하는 작업
추상 메서드 대신 아무 내용 없이 메서드를 작성하는 것도 가능하지만, 추상화를 사용하면 자손 클래스에 내용 구현을 강제할 수 있다는 차이가 있다.
Object 클래스 타입의 배열로도 서로 다른 종류의 인스턴스를 하나의 묶음으로 다룰 수 있지만, Object 클래스에 정의되어 있지 않은 메서드를 사용하려고 하면 오류가 발생한다. 이 경우 아래와 같이 Object 배열의 값을 Unit 참조변수에 넣으면 코드가 잘 작동했다.
7. 인터페이스(interface)
인터페이스는 일종의 추상클래스이다. 인터페이스는 추상클래스처럼 추상메서드를 갖지만 추상클래스보다 추상화 정도가 높아서 추상클래스와 달리 몸통을 갖춘 일반 메서드 또는 멤버변수를 구성원으로 가질 수 없다.
추상 클래스를 부분적으로만 완성된 '미완성 설계도'라고 한다면, 인터페이스는 구현된 것은 아무 것도 없고 밑그림만 그려져 있는 '기본 설계도'라 할 수 있다.
인터페이스는 그 자체만으로 사용되기 보다는 다른 클래스를 작성하는 데 도움을 줄 목적으로 작성된다.
인터페이스를 작성할 때에는 키워드로 'interface'를 사용하며, 접근 제어자는 public 또는 default를 사용할 수 있다.
▼ 인터페이스의 선언
interface 인터페이스이름 {
public static final 타입 상수이름 = 값;
public abstract 메서드이름(매개변수목록);
}
인터페이스에 정의된 모든 멤버에 예외없이 적용되는 제어자 일부는 생략할 수 있다. 생략된 제어자는 컴파일 시에 컴파일러가 자동적으로 추가해준다.
[ 인터페이스 멤버들의 특징 ]
- 모든 멤버변수는 public static final이어야 하며, 이를 생략할 수 있다.
- 모든 메서드는 public abstract이며, 이를 생략할 수 있다.
: 본래 인터페이스의 모든 메서드는 추상메서드여야 하지만, JDK 1.8부터 인터페이스에 static 메서드와 디폴드 메서드(default method)의 추가를 허용하는 방향으로 변경되었다.
인터페이스는 인터페이스로부터만 상속받을 수 있으며, 클래스와는 달리 다중상속, 즉 여러 개의 인터페이스로부터 상속을 받는 것이 가능하다. 이때 클래스의 상속과 마찬가지로 조상 인터페이스에 정의된 멤버들을 모두 상속받는다. 이때 인터페이스는 구현한다는 의미로 키워드 'implements'를 사용한다. 이때 'extends'로 상속을 받는 것과 인터페이스를 구현하는 것을 모두 동시에 할 수도 있다.
- 클래스가 클래스를 상속받음 -> extends
- 인터페이스가 인터페이스를 상속받음 -> extends
- 클래스가 인터페이스를 상속받음 -> implements
인터페이스도 추상 클래스처럼 그 자체로는 인스턴스를 생성할 수 없기 때문에 인터페이스를 상속하는 클래스를 작성해야 한다. 만약 구현하는 인터페이스의 메서드 중 일부만 구현한다면 abstract를 붙여서 추상클래스로 선언해야 한다.
▼ 인터페이스의 구현
class 클래스이름 implements 인터페이스이름 {
// 인터페이스에 정의된 추상 메서드를 구현해야 한다.
}
이때 추상 메서드를 구현하는 데 있어 접근 제어자를 반드시 조상의 메서드보다 넓은 범위(ex. public)로 작성해야 한다. 이는 오버라이딩에 해당하기 때문이다.
인터페이스는 static 상수만 정의할 수 있으므로 조상클래스의 멤버변수와 충돌하는 경우는 거의 없고, 충돌한다 해도 클래스 이름을 붙여서 구분이 가능하다. 또한 추상 메서드는 구현 내용이 전혀 없으므로 조상클래스의 메서드와 선언부가 일치하는 경우에는 조상 클래스 쪽의 메서드를 상속받으면 되기 때문에 문제가 발생하지 않는다.
그러나 이렇게 하면 충돌은 피할 수 있지만 다중상속의 장점을 잃어버린다. 만약 두 개의 클래스로부터 상속을 받아야 한다면, 비중이 높은 쪽을 상속으로 받고 낮은 쪽은 멤버로 포함시키거나, 혹은 한쪽의 필요한 부분을 뽑아서 인터페이스로 만든 다음 구현하도록 한다. 아래는 코드의 예시다.
public class Tv {
protected boolean power;
protected int channel;
protected int volum;
public void power() { power != power; }
public void channelUp() { channel++; }
public void channelDown() { channel--; }
public void volumeUp() { volume++; }
public void volumeDown() { volume--; }
}
public class VCR {
protected int counter;
public void play() { }
public void stop() { }
public void reset() { counter = 0; }
public int getCounter() { return counter; }
pubic void setCounter(int c) { counter = c; }
}
public interface IVCR {
public void play();
pubic 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();
}
public void reset() {
vcr.reset();
}
public int getCounter() {
return vcr.getCounter();
}
public void setCounter(int c) {
vcr.setCounter(c);
}
}
위 코드는 VCR 클래스에서 필요한 멤버만을 뽑아 인터페이스인 IVCR을 만들었고, TVCR 클래스가 Tv 클래스는 상속, IVCR 인터페이스는 구현하도록 만들었다.
인터페이스에서도 다형성을 구현하는 것이 가능한데, 클래스에서 자손 클래스의 인스턴스를 조상 타입의 참조변수로 참조했던 것처럼, 인터페이스 타입의 참조변수로 이를 구현한 클래스의 인스턴스를 참조할 수 있다. 따라서 인터페이스는 클래스가 그랬던 것처럼 메서드의 매개변수 타입으로 사용될 수 있다.
interface Movable {
// 지정된 위치(x, y)로 이동하는 기능의 메서드
void move(int x, int y);
}
interface Attackable {
// 지정된 대상(u)을 공격하는 기능의 메서드
void attack(Unit u);
}
interface Fightable extends Movable, Attackable { }
class Unit {
int currentHP;
int x;
int y;
}
class Fighter extends Unit implements Fightable {
public void move(int x, int y) { }
public void attack(Unit u) { }
// 만약 다형성의 경우, attack 메서드의 매개변수는 아래와 같이 바꿀 수 있다.
// public void attack(Fightable f) { }
}
class FighterTest {
public static void main(String args[]) {
// 다형성 선언
Fightable f = new Fighter(); // new 앞에 (Fightable) 형변환 생략 가능
}
}
인터페이스 타입의 매개변수가 갖는 의미는 메서드 호출 시 해당 인터페이스를 구현한 클래스의 인스턴스를 매개변수로 제공해야 한다는 것이다. 따라서 attack 메서드 실행 시 매개변수는 Unit이 아닌 매개변수로 Fightable 인터페이스를 구현한 클래스의 인스턴스(Fighter)을 넘겨줘야 한다. 즉, attack(new Fighter())과 같다.
또한 함수의 리턴 타입으로 인터페이스의 타입을 반환할 수도 있다.
리턴 타입이 인터페이스라는 것은 메서드가 해당 인터페이스를 구현한 클래스의 인스턴스를 반환한다는 것을 의미한다.
interface Parseable {
// 구문 분석작업을 수행한다.
public abstract void parse (String fileName);
}
class ParseManager {
// 리턴타입이 Parseable 인터페이스다.
public static Parseable getParser (String type) {
if (type.equals("XML")) {
return new XMLParser();
} else {
Parseable p = new HTMLParser();
return p;
}
}
}
class XMLParser implements Parseable {
public void parse (String fileName) {
// 구분 분석작업을 수행하는 코드를 적는다.
System.out.println(fileName + " - XML parsing complete.");
}
}
class HTMLParser implements Parseable {
public void parse (String fileName) {
// 구문 분석작업을 수행하는 코드를 적는다.
System.out.println(fileName + " - HTML parsing complete.");
}
}
class ParseTest {
public static void main (String args[]) {
Parseable parser = PaserManager.getParser("XML");
parser.parse("document.xml");
parser = ParserManager.getParser("HTML");
parser.parse("document.html");
}
}
Parseable 인터페이스는 구문분석(parsing)을 수행하는 기능을 구현할 목적으로 추상메서드 parse(String fileName)을 정의했다. 그리고 XMLParser 클래스와 HTMLParser 클래스는 Parseable 인터페이스를 구현하였다.
ParseManager 클래스의 getParser 메서드는 매개변수로 넘겨받는 type의 값에 따라 XMLParser 또는 HTMLParser 인스턴스를 반환한다.
이후 참조변수 parser를 통해 parse 메서드를 호출하면 parser가 참조하고 있는 인스턴스에 따라 각 클래스의 parse 메서드가 호출된다.
만약 나중에 새로운 종류의 XML구문분석기 NewXMLParser 클래스가 나와도 ParseTest 클래스는 변경할 필요 없이 ParserManager 클래스의 getParser 메서드에서 'return new NewXMLParser();'을 추가해주면 된다.
이러한 장점은 분산환경 프로그래밍에서 위력을 발휘한다. 사용자 컴퓨터에 설치된 프로그램을 변경하지 않고 서버 측의 변경만으로도 사용자가 새로 개정된 프로그램을 사용하는 것이 가능하다.
[ 인터페이스의 장점 ]
- 개발시간을 단축시킬 수 있다.
일단 인터페이스가 작성되면, 이를 사용해서 프로그램을 작성하는 것이 가능하다. 메서드를 호출하는 쪽에서 메서드의 내용과 관계없이 선언부만 알면 되기 때문이다. 또한 동시에 다른 쪽에서 인터페이스를 구현하는 클래스를 작성하면, 프로그램을 개발하는 것과 동시에 클래스의 완성이 가능하다. - 표준화가 가능하다.
프로젝트에 사용되는 기본 틀을 인터페이스로 작성한 다음, 개발자들에게 인터페이스를 구현하여 프로그램을 작성하도록 하면 일관되고 정형화된 프로그램의 개발이 가능하다. - 서로 관계없는 클래스들에게 관계를 맺어줄 수 있다.
서로 상속관계에 있지 않고, 같은 조상 클래스를 가지고 있지 않은 클래스들을 하나의 인터페이스로 공통적으로 구현하도록 관계를 맺어줄 수 있다. - 독립적인 프로그래밍이 가능하다.
클래스의 선언과 구현을 분리시킬 수 있기 때문에 실제구현에 독립적인 프로그램을 작성하는 것이 가능하다. 클래스와 클래스 간의 직접적인 관계를 인터페이스를 이용해서 간접적인 관계로 변경하면, 한 클래스의 변경이 관련된 다른 클래스에 영향을 미치지 않는 독립적인 프로그래밍이 가능하다.
만약 데이터베이스 회사에서 특정 데이터베이스를 사용하는 데 필요한 클래스를 이용해 프로그램을 짰는데, 다른 종류의 데이터베이스를 이용해야 한다면 전체 프로그램에서 데이터베이스와 관련된 부분은 전부 수정해야 한다.
그러나 데이터베이스 관련 인터페이스를 정의하고 이를 이용해서 프로그램을 작성하면 그럴 필요가 없어진다.
교재 p.391부터는 예제를 나타내는데, 이곳에서는 클래스의 공통이 아니라 "기계화 유닛"이라는 공통점을 가지고 있는 SVC, Tank, Dropship 클래스를 Repairable이라는 인터페이스로 묶는 부분이 나온다. 이후 SVC 클래스에 repair 메서드를 추가하는데, 이때 매개변수는 인터페이스 타입인 Repairable을 받는다. 이로 인해 해당 메서드의 매개변수로 Repairable 인터페이스를 구현하는 다른 클래스 인스턴스를 전달하는 것이 가능해진다.
그 외에도 p. 394의 건물 클래스 예제 등이 있다.
[ 인터페이스 사용 시 유의점 ]
- 클래스를 사용하는 쪽(User)과 클래스를 제공하는 쪽(Provider)이 있다.
- 메서드를 사용(호출)하는 쪽(User)에서는 사용하려는 메서드(Provider)의 선언부만 알면 된다. (내용은 몰라도 된다.)
class A {
public void methodA (B b) {
b.methodB();
}
}
class B {
public void methodB() {
System.out.println("methodB()");
}
}
class InterfaceTest {
public static void main (String args[]) {
A a = new A();
a.methodA(new B());
}
}
만약 위의 코드에서 클래스 A를 작성하기 위해서는 클래스 B가 이미 작성되어 있어야 한다. 또한 클래스 B의 methodB()의 선언부가 변경되면, 이를 사용하는 클래스 A도 변경되어야 한다. 이 경우 클래스 B가 Provider, 클래스 A가 User다.
이와 같이 직접적인 관계의 두 클래스는 한 쪽(Provider)이 변경되면 다른 쪽(User)도 변경되어야 하나는 단점이 있다.
그러나 클래스 A가 클래스 B를 직접 호출하지 않고 인터페이스를 매개체로 해서 클래스 B의 메서드에 접근하도록 하면, 클래스 B에 접근사항이 생기거나 클래스 B의 다른 기능이 대체되어도 클래스 A는 전혀 영향을 받지 않도록 하는 것이 가능하다. 아래는 변경된 코드다.
interface I {
public abstract void methodB();
}
class B implements I {
public void methodB() {
System.out.println("methodB in B class");
}
}
class A {
public void methodA(I i) {
i.methodB();
}
}
class InterfaceTest {
public static void main (String args[]) {
A a = new A();
a.methodB(new B());
}
}
위 코드와 같이 매개변수를 통해 동적으로 제공받을 수도 있지만, 다음과 같이 제 3의 클래스를 통해 제공받을 수도 있다. JDBC의 DriveManager 클래스가 이런 방식으로 되어 있다.
class InterfaceTest3 {
public static void main (String args[]) {
A a = new A();
a.methodA();
}
}
class A {
void methodA() {
I i = InstanceManager.getInstance(); // 제 3의 클래스의 메서드를 통해
// 인터페이스 I를 구현한 클래스의 인스턴스를 얻어온다.
i.methodB();
System.out.println(i.toString()); // i로 Object클래스의 메서드도 호출 가능
}
}
interface I {
public abstract void methodB();
}
class B implements I {
public void methodB() {
System.out.println("method B in class");
}
public String toString() { return "class B"; }
}
class InstanceManager {
public static I getInstance() {
return new B();
}
}
인스턴스를 직접 생성하지 않고 getInstance()라는 메서드를 통해 제공받는다. 이렇게 하면, 나중에 다른 클래스의 인스턴스로 변경되어도 A 클래스의 변경없이 getInstance()만 변경하면 된다는 장점이 생긴다. 그리고 인터페이스 타입의 참조변수 i로도 Object 클래스에 정의된 메서드들을 호출할 수 있다.
원래 인터페이스에는 추상 메서드만 선언할 수 있는데, JDK1.8부터 디폴트 메서드와 static 메서드도 추가할 수 있게 되었다. 그러나 자바를 쉽게 배우기 위해서는 규칙에 예외를 두면 안되기 때문에, 인터페이스와 관련된 static 메서드는 별도의 클래스에 따로 두어야 했다. (ex. java.util.Collection - Collections라는 클래스에 static 메서드들 존재)
그리고 인터페이스의 static 메서드 역시 접근 제어자가 항상 public이며, 생략할 수 있다.
인터페이스에 새 메서드를 추가하면, 이 인터페이스를 구현한 모든 클래스에 해당 메서드를 추가해주어야 한다. 그러나 디폴트 메서드는 추가 되어도 클래스에서 구현할 필요가 없다. 디폴트 메서드는 앞에 키워드 default를 붙이며, 추상 메서드와 달리 일반 메서드처럼 몸통{ }이 있어야 한다. 디폴트 메서드 역시 접근 제어자가 public이며, 생략 가능하다.
interface MyInterface {
void method();
// 추상 메서드
// void newMethod();
// 디폴트 메서드
default void newMethod() {}
}
[ 디폴트 메서드의 충돌 해결 방법 ]
- 여러 인터페이스의 디폴트 메서드 간의 충돌
인터페이스를 구현한 클래스에서 디폴트 메서드를 오버라이딩 해야한다. - 디폴트 메서드와 조상 클래스의 메서드 간의 충돌
조상 클래스의 메서드가 상속되고, 디폴트 메서드는 무시된다
위의 규칙을 외우기 귀찮다면, 그냥 필요한 쪽의 메서드를 같은 내용으로 오버라이딩 해버리면 그만이다.
8. 내부 클래스(inner class)
내부 클래스는 클래스 내에 선언된 클래스이다. 내부 클래스를 선언하는 이유는 두 클래스가 서로 긴밀한 관계에 있기 때문이다.
[ 내부 클래스의 장점 ]
- 내부 클래스에서 외부 클래스의 멤버들을 쉽게 접근할 수 있다.
- 코드의 복잡성을 줄일 수 있다(캡슐화).
이때 내부 클래스는 외부 클래스를 제외하고 다른 클래스에서 잘 사용되지 않는 것이어야 한다.
내부 클래스의 종류는 변수의 선언위치에 따른 종류와 같다.
▼ 내부 클래스의 종류와 특징
내부 클래스 | 특징 |
인스턴스 클래스 (instance class) |
외부 클래스의 멤버변수 선언 위치에 선언하며, 외부 클래스의 인스턴스 멤버처럼 다루어진다. 주로 외부 클래스의 인스턴스 멤버들과 관련된 작업에 사용될 목적으로 선언된다. |
스태틱 클래스 (static class) |
외부 클래스의 멤버변수 선언 위치에 선언하며, 외부 클래스의 static 멤버처럼 다루어진다. 주로 외부 클래스의 static 멤버, 특히 static 메서드에서 사용될 목적으로 선언된다. |
지역 클래스 (local class) |
외부 클래스의 메서드나 초기화 블럭 안에 선언하며, 선언된 영역 내부에서만 사용될 수 있다. |
익명 클래스 (anonymous class) |
클래스의 선언과 객체의 생성을 동시에 하는 이름없는 클래스(일회용) |
내부클래스를 선언하는 것은 아래의 코드와 같다.
class Outer {
class InstanceInner { }
static class StaticInner { }
void myMethod() {
class LocalInner { }
}
}
인스턴스 클래스와 스태틱 클래스에는 인스턴스 멤버와 static 멤버 간의 규칙이 그대로 똑같이 적용된다. 또한 내부 클래스도 클래스이기 때문에 abstract나 final과 같은 제어자나 private, protected와 같은 접근 제어자도 사용 가능하다.
내부 클래스 중에서 스태틱 클래스만 static 멤버를 가질 수 있다. 만약 내부 클래스에 static 변수를 선언해야 한다면, 해당 내부 클래스는 static 클래스로 선언해야 한다. 다만 final과 static이 동시에 붙은 변수는 상수(constant)이므로 모든 내부 클래스에서 사용 가능하다.
인스턴스 멤버는 같은 클래스에 있는 인스턴스 멤버와 static 멤버 모두 직접 호출이 가능하지만, static 멤버는 인스턴스 멤버를 직접 호출할 수 없다. 또한 인스턴스 클래스는 외부 클래스의 인스턴스 멤버를 객체 생성 없이 바로 쓸 수 있지만, 스태틱 클래스는 외부 클래스의 인스턴스 멤버를 객체를 생성해야 사용할 수 있다. (이는 static 메서드가 같은 클래스 내의 인스턴스 메서드를 호출할 수 없는 것과 같은 이유다. 인스턴스 멤버를 호출했지만 해당 인스턴스가 생성되지 않았을 가능성이 있기 때문이다.)
인스턴스 클래스는 외부 클래스의 변수를 모두 사용할 수 있으며, static 클래스는 static 변수에만 접근 가능하다. 지역 클래스는 외부 클래스의 모든 변수를 사용할 수 있으며, 지역 클래스가 포함된 메서드에 정의된 지역 변수도 사용할 수 있다. 단, final이 붙은 지역 변수만 가능하다.
컴파일 했을 때 내부 클래스는 '외부 클래스명$내부 클래스명.class' 형식으로 컴파일 된다. 만약 다른 메서드에 서로 같은 이름의 내부 클래스가 존재한다면 내부 클래스 앞에 숫자가 붙는다. (ex. Outer$2LocallInner.class)
익명 클래스는 클래스의 선언과 객체의 생성을 동시에 하기 때문에 단 한 번만 사용될 수 있고, 오직 하나의 객체만을 생성하는 일회용 클래스이다. 이름이 없기 때문에 생성자도 가질 수 없으며, 조상클래스의 이름이나 구현하고자 하는 인터페이스의 이름을 사용해서 정의하기 때문에 하나의 클래스로 상속받는 동시에 인터페이스를 구현하거나, 둘 이상의 인터페이스를 구현할 수 없다. 오로지 단 하나의 클래스를 상속받거나, 단 하나의 인터페이스만을 구현할 수 있다.
▼ 익명 클래스의 선언
new 조상클래스이름() {
// 멤버 선언
} // 또는
new 구현인터페이스이름() {
// 멤버 선언
}
// 예시
class InnerEx6 {
Object iv = new Object() { void method() {} }; // 익명 클래스
static Object cv = new Object() { void method() {} }; // 익명 클래스
void myMethod() {
Object lv = new Object() { void method() {} };
}
}
익명 클래스는 이름이 없기 때문에 '외부 클래스명$숫자.class'의 형식으로 클래스파일명이 결정된다. (ex. InnerEx6$1.class)
'JAVA' 카테고리의 다른 글
Chapter 8, 9 요약 (1) | 2022.09.17 |
---|---|
Chapter 6 요약 (0) | 2022.08.26 |
Chapter 4, 5 요약 (0) | 2022.08.19 |
Chapter 1, 2, 3 요약 (0) | 2022.08.12 |