abstract, 왜 사용할까?
간단한 예로 abstract의 필요성을 알아봅시다.
public class Driver {
public static void main(String[] args) {
동물[] 동물들 = new 동물[5];
동물들[0] = new 쥐();
동물들[1] = new 고양이();
동물들[2] = new 강아지();
동물들[3] = new 송아지();
동물들[4] = new 병아리();
for (int i = 0; i < 동물들.length; i++) {
동물들[i].울어보세요();
}
}
}
나는 쥐! 찍! 찍!
나는 고양이! 야옹! 야옹!
나는 강아지! 멍! 멍!
나는 송아지! 음메! 음메!
나는 병아리! 삐약! 삐약!
Process finished with exit code 0
저 동물들의 슈퍼 클래스인 동물 클래스를 살펴봅시다.
public class 동물 {
String name;
동물() {
name = "동물";
}
public void 울어보세요() {
System.out.printf("나는 %s! 어떻게 울어야 하나요?\n", name);
}
}
쥐
, 고양이
, 강아지
, 송아지
, 병아리
등은 울음이라는 공통적인 행위를 갖고 있으니 동물의 울어보세요()
메서드를 오버라이딩하는 게 맞는 거 같습니다. 하지만 몇 가지 애매한 부분들이 있습니다.
- 동물 클래스 인스턴스는 어떻게 울어야 하는지 알 수 없다.
- 실수로 동물 클래스의 인스턴스를 만들고,
울어보세요()
메서드를 호출하면 난감해진다.
- 실수로 동물 클래스의 인스턴스를 만들고,
- 동물 클래스의
울어보세요()
메서드의 몸체를 비워두는 것도 이상하다.
바로 이럴 때 추상 클래스를 사용하게 됩니다.
동물 클래스를 추상 클래스로 바꾸어봅시다.
public abstract class 동물 {
String name;
public abstract void 울어보세요();
}
위 두 가지 문제점을 해결했습니다.
- 동물 클래스는 인스턴스화될 일이 없으므로 동물 인스턴스가 어떻게 울어야 하는지 걱정할 필요가 없다.
울어보세요()
메서드의 구현을 하위 클래스에게 맡겼으므로 몸체를 비워두는 것이 당연하다.- 그냥 맡기는 것이 아니다. 책임을 강제하는 것이다.
생성자
- 생성자를 따로 정의하지 않아도 컴파일 시에 생성되는 생성자가 있다. 이를 기본 생성자라고 부른다.
- 기본 생성자 외 별도의 생성자를 정의하면 컴파일 시에 기본 생성자는 생성되지 않는다.
static 블록
static 블록은 클래스가 스태틱 영역에 배치될 때 실행되는 코드 블록입니다. 다음 코드로 살펴보겠습니다.
public class 동물 {
static {
System.out.println("동물 클래스 레디 온!");
}
}
public class Driver01 {
public static void main(String[] args) {
동물 뽀로로 = new 동물();
}
}
동물 클래스 레디 온!
Process finished with exit code 0
동물 클래스가 스태틱 영역에 배치될 때 실행되기 때문에 위와 같은 결과가 나오게 됩니다. 그렇다면 다음 코드의 실행 결과는 어떻게 될까요?
public class Driver02 {
public static void main(String[] args) {
System.out.println("main 메서드 시작!");
}
}
main 메서드 시작!
Process finished with exit code 0
이 경우에는 다음과 같은 이유로 static 블록이 실행되지 않습니다.
- 동물 클래스를 사용하는 코드가 없기에 동물 클래스는 스태틱 영역에 배치되지 않는다.
- 동물 클래스가 스태틱 영역에 배치되지 않았기 때문에 static 블록을 실행하지 않는다.
2장에서는 프로그램이 시작될 때 모든 패키지와 모든 클래스가 T 메모리의 스태틱 영역에 로딩된다고 배운적이 있습니다. 하지만 실제로는 해당 패키지 또는 클래스가 처음으로 사용될 때 로딩되는 것이 맞습니다.
마지막으로 클래스의 인스턴스를 만드는 작업이 아닌 클래스의 정적 속성에 접근하는 예를 살펴보겠습니다.
public class Animal {
static int age = 0;
static {
System.out.println("Animal class ready on!");
}
}
public class Driver05 {
public static void main(String[] args) {
System.out.println("main 메서드 시작!");
System.out.println(Animal.age);
}
}
main 메서드 시작!
Animal class ready on!
0
Process finished with exit code 0
어느 정도 정리가 된 거 같습니다. 정리해보겠습니다.
- 클래스 정보는 해당 클래스가 코드에서 맨 처음 사용될 때 T 메모리의 스태틱 영역에 로딩된다.
- 이 때, 단 한 번 해당 클래스의 static 블록이 실행된다.
- 클래스가 제일 처음 사용될 때는 다음 세 가지 경우 중 하나이다.
- 클래스의 정적 속성을 사용할 때
- 클래스의 정적 메서드를 사용할 때
- 클래스의 인스턴스를 최초로 만들 때
- 프로그램 실행 시 발로 클래스들의 정보를 T 메모리의 static 영역에 로딩하지 않는 이유?
- 결국, 스태틱 영역도 메모리이기 때문
- 메모리는 최대한 늦게 사용을 시작하고 최대한 빨리 반환하는 게 정석
- 메모리에 최대한 늦게 로딩함으로써 메모리 사용을 최대한 늦추기 위함
- static 블록과 유사하게 인스턴스를 위한 인스턴스 블록도(
{}
) 존재- 아무런 표시없는
{}
- 객체 생성자가 실행된기 전에 먼저 실행됨
- 아무런 표시없는
final 키워드
final
은 마지막, 최종이라는 의미를 가진 단어입니다. final
은 객체 지향 언어의 구성 요소의 전부인 클래스, 변수, 메서드 각각에 붙을 때마다 다른 현상(?)을 보이지만, 결국 그 의미는 같습니다. 같이 한 번 확인해봅시다.
final이 클래스에 붙었을 때
public final class 고양이 {
}
public class 길고양이 extends 고양이 {
}
이 경우 다음과 같은 컴파일 에러가 표시됩니다.
- The type 길고양이 cannot subclass the final class 고양이
즉, 상속을 허락하지 않겠다는 의미입니다.
자, 이제 final
의 의미를 떠올려봅시다. 고양이
는 최종적인 클래스입니다. 따라서, 상속할 필요가 없는 것이죠.
final이 변수에 붙었을 때
public class 고양이 {
final static int 정적상수1 = 1;
final static int 정적상수2;
final int 객체상수1 = 1;
final int 객체상수2;
static {
정적상수2 = 2;
//상수는 한 번 초기화되면 값을 변경할 수 없다.
//정적상수2 = 4;
}
고양이() {
객체상수2 = 2;
//상수는 한 번 초기화되면 값을 변경할 수 없다.
//객체상수2 = 4;
final int 지역상수1 = 1;
final int 지역상수2;
지역상수2 = 2;
}
}
자바에서 상수는 선언 후에 초기화가 가능합니다. 단, 한 번만 초기화가 가능합니다. 다른 언어에서는 상수에 const
키워드를 사용하는데, 자바는 초기화가 가능한 점을 두고 다른 언어와 차별을 두어 final
키워드를 사용합니다.
자, 이제 final
의 의미를 떠올려봅시다. 변수가 최종적이라는 뜻입니다. 따라서, 더 이상 값을 변경할 필요가 없다는 뜻입니다.
final이 메서드에 붙었을 때
public class 동물 {
final void 숨쉬다() {
System.out.println("호흡 중");
}
}
public class 포유류 extends 동물 {
void 숨쉬다() {
System.out.println("호흡 중");
}
}
이 경우 다음과 같은 컴파일 에러가 표시됩니다.
- Cannot override the final method from 동물
즉, 오버라이딩을 허용하지 않겠다는 뜻입니다.
자, 이제 final
의 의미를 떠올려봅시다. 숨쉬다()
메서드는 최종적인 메서드입니다. 따라서, 더 이상 재정의할 필요가 없겠죠.
instanceof 연산자
- 객체가 특정 클래스의 인스턴스인지 물어보는 연산자이다.
- 맞으면 true, 아니면 false를 반환한다.
- 객체 참조 변수의 타입이 아닌 실제 객체의 타입에 의해 처리한다.
- LSP를 어기는 코드에서 주로 나타나는 연산자이다.
- 리팩터링의 대상이 아닌지 점검해볼 필요가 있다.
packge 키워드
- 이름공간을 만든다.
interface 키워드와 implements 키워드
interface는 public 추상 메서드와 public 정적 상수만 가질 수 있습니다. 다음 코드를 확인해봅시다.
public interface Speakable {
double PI = 3.14159;
final double absoluteZeroPoint = -275.15;
void sayYes();
}
public class Speaker implements Speakable {
public void sayYes() {
System.out.println("I say NO!!!");
}
}
public class Driver {
public static void main(String[] args) {
System.out.println(Speakable.absoluteZeroPoint);
System.out.println(Speakable.PI);
Speaker reporter1 = new Speaker();
reporter1.sayYes();
}
}
Speakable
의 PI
와 absoluteZeroPoint
는 정적 상수로 선언이 되어 있지 않음에도 컴파일 에러가 나지 않습니다. 어떻게 된 일일까요?
인터페이스는 추상 메서드와 정적 상수만 가질 수 있기 때문에 따로 메서드에 public과 abstract, 속성에 public과 static, final을 붙이지 않아도 자동으로 자바가 알아서 붙여줍니다. 결국, 자바가 설계가 잘 되어 있기 때문에 발생한 일이라고 보시면 됩니다.
자바에서 람다의 도입은 언어적으로 큰 변화를 이뤄냈습니다. 이 람다가 바로 인터페이스를 기초로 하고 있습니다. 이에 따라 인터페이스에도 큰 변화가 생겼는데, 자바 8 이전까지 즉, 람다의 도입 전까지 인터페이스는 정적 상수와 객체 추상 메서드만 가질 수 있었지만 자바 8부터는 디폴트 메서드라고 하는 객체 구상 메서드와 정적 추상 메서드를 지원할 수 있게 언어 스펙이 바뀌었습니다.
this 키워드
this는 객체가 자기 자신을 지칭할 때 쓰는 키워드입니다. 여기서 중요한 건 this의 대상이 객체라는 겁니다.
일단 정리해보겠습니다.
- 지역 변수와 속성의 이름이 같은 경우 지역 변수가 우선한다.
- 객체 변수와 이름이 같은 지역 변수가 있는 경우 객체 변수를 사용하려면
this
를 접두사로 사용한다.
- 정적 변수와 이름이 같은 지역 변수가 있는 경우 정적 변수를 사용하려면 클래스명을 접두사로 사용한다.
마지막 내용이 중요합니다. 왜 정적 변수와 이름이 같은 지역 변수가 있을 때 this
가 아닌 클래스명을 접두사로 사용해야 할까요? this
는 객체를 가리키기 때문입니다. 정적(static) 변수는 객체가 아니라 클래스에 즉, 힙 영역이 아니라 스태틱 영역에 위치하기 때문에 this
를 사용하지 않는 겁니다.
super 키워드
- 바로 상위 클래스의 인스턴스를 지칭하는 키워드입니다.
this
와 느낌이 비슷한 키워드입니다.
super.super
형태로 상위의 상위 클래스의 인스턴스에는 접근이 불가능합니다.
ADVANCED
public class 펭귄 {
void test() {
System.out.println("Test");
}
}
public class Driver {
public static void main(String[] args) {
펭귄 뽀로로 = new 펭귄();
뽀로로.test();
}
}

펭귄
인스턴스인 뽀로로
의 test()
를 호출했으므로 스택에 쌓여진 스택 프레임은 펭귄
이 아닌 뽀로로
가 있어야 할 거 같지만 Debugger는 펭귄
의 스택 프레임이 쌓여 있다고 알려주고 있습니다.
이는 조금만 생각해보면 왜 JVM이 그런 선택을 한 건지 이해할 수 있습니다. 펭귄
인스턴스 100개가 있다면, test()
메서드도 각 펭귄
객체에 따라 힙 영역에 100개가 만들어져야 합니다. 하지만 객체 멤버 메서드(test()
메서드)는 각 객체별로 달라지는 건 없습니다. 사용하는 객체 멤버 속성의 값만 다를 뿐이죠. 즉, 똑같은 객체 멤버 메서드인 test()
메서드를 힙 영역에 100개나 만드는 건 심각한 메모리 낭비입니다. 이것이 객체 멤버 메서드 test()
를 스태틱 영역에 단 하나만 보유하는 이유입니다.
그리고 눈에 보이지는 않지만 test()
메서드를 호출할 때 객체 자신을 나타내는 this
객체 참조 변수를 넘깁니다. 그렇게 함으로써 클래스(펭귄
)의 스택 프레임에서도 객체에 대해 참조를 할 수 있는 것이죠. 즉, 위의 코드는 JVM에 의해 아래와 같이 변경된다고 생각하면 됩니다.
public class 펭귄 {
static void test(펭귄 this) {
System.out.println("Test");
}
}
public class Driver {
public static void main(String[] args) {
펭귄 뽀로로 = new 펭귄();
펭귄.test(뽀로로);
}
}
Uploaded by Notion2Tistory v1.1.0