클래스가 아니라는 것은 다른 말로 참조타입(reference type)이 아니라는 뜻이다.
당장 적당한 예시가 떠오르지 않지만, 이 기본 타입들에 대해 객체로 표현해야 하는 경우가 있는데 문제는 참조타입이 아니기 때문에 객체를 생성하지 못하는 문제가 있다.
그래서 Java에는 Wrapper(래퍼) 라고 부르는 클래스가 존재한다.
이 클래스들은 기본 자료형을 참조타입으로 객체를 생성할 수 있도록 해준다.
기본 타입 (primitive type)
래퍼클래스 (Wrapper class)
byte
Byte
short
Short
int
Integer
long
Long
float
Float
double
Double
char
Character
boolean
Boolean
Java에서는 primitive type 과 Wrapper 타입의 변환을 자동으로 해주는데, 이걸을 AutoBoxing 이라고 한다.
다음 코드는 박싱과 언박싱, 그리고 자동 박싱과 자동 언박싱의 예를 보여준다.
package com.tistory.xxxelppa.section_001;
public class BoxingUnboxing {
public static void main(String[] args) {
Integer myWrapperNumber = new Integer(21); // Boxing
int myPrimitiveNumber = myWrapperNumber.intValue(); // Unboxing
Integer myAutoBoxingNumber = 21; // AutoBoxing
int myAutoUnBoxingNumber = myAutoBoxingNumber; // AutoUnBoxing
}
}
꽤나 편리해 보이는데 왜 되도록 피하라고 했을까?
728x90
다음 코드를 보자
private static long sum_with_autoboxing() {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; ++i) {
sum += i;
}
return sum;
}
그다지 문제가 있어보이지 않는 0부터 4바이트 정수 자료형 int 의 최대 표현 범위의 값의 누적 합을 계산해 long 타입의 변수에 담는 코드이다.
조금 더 정확히 얘기하면 long 타입의 i 값을 Long 타입의 변수에 누적 합을 구하고 있다.
문제는 sum += i; 이 부분이다.
Wrapper 타입의 변수에 값을 할당할 때 우리 눈에는 그저 sum += i; 만 보이지만
컴파일러는 이 코드를 sum += Long.valueOf(i); 로 바꾸기 때문이다.
즉, 저 라인이 실행 될 때마다 Long 타입의 객체를 생성 한다는 것을 의미한다.
지금의 경우 21억개의 무의미한 객체를 생성했다고 생각하면 될 것 같다.
그래서 위의 코드에서 딱 한 문자인 L 을 l 로 고쳐보았다.
private static long sum_without_autoboxing() {
long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; ++i) {
sum += i;
}
return sum;
}
그럼 이게 얼마나 차이가 나는걸까?
package com.tistory.xxxelppa.section_001;
/**
* autoboxing 테스트
*
* [실행 결과]
* use autoboxing : 6644 ms
* not use autoboxing : 609 ms
*
* 되도록 박싱된 기본 타입보다 기본 타입을 사용하고
* 의도하지 않은 오토박싱이 사용되지 않도록 주의 필요
*
*/
public class Main {
public static void main(String[] args) {
long startTime, endTime;
startTime = System.currentTimeMillis();
sum_with_autoboxing();
endTime = System.currentTimeMillis();
System.out.println("use autoboxing : " + (endTime - startTime) + " ms");
startTime = System.currentTimeMillis();
sum_without_autoboxing();
endTime = System.currentTimeMillis();
System.out.println("not use autoboxing : " + (endTime - startTime) + " ms");
}
private static long sum_with_autoboxing() {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; ++i) {
sum += i;
}
return sum;
}
private static long sum_without_autoboxing() {
long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; ++i) {
sum += i;
}
return sum;
}
}
코드의 맨 위에 주석으로 실행 결과를 기록해 두었다.
매변 결과는 조금씩 다르겠지만, 6000ms 즉 6초나 더 오래 걸린것을 확인할 수 있었다.
물론 이렇게 극단적인 상황이 자주 발생하지는 않겠지만, Auto Boxing 을 사용했을 때 내부에서 어떤 일이 생기는지 알고 있으면 좋을것 같아서 정리해 보았다.
익명 구현 객체는 인터페이스나 클래스의 객체를 생성해서 사용할 때, 재사용하지 않는 경우 보통 사용한다.
예를 들어 보자.
특정 인터페이스를 사용하기 위해 이 인터페이스를 상속 받은 클래스를 구현하는 방법이 있다.
package me.xxxelppa.study.week15;
public class Exam_001 {
public static void main(String[] args) {
Exam_001_Sub exam_001_sub = new Exam_001_Sub();
exam_001_sub.doSomething();
}
}
interface AnonymousTest_001 {
void doSomething();
}
// 인터페이스를 구현한 클래스 정의
class Exam_001_Sub implements AnonymousTest_001 {
@Override
public void doSomething() {
System.out.println("do something !!");
}
}
do something !!
그런데 만약 Exam_001_Sub 클래스가 AnonymousTest 인터페이스의 구현체로만 사용하고, Exam_001_Sub 클래스가 재사용 되지 않는다고 하면 새로운 클래스 파일을 생성해서 관리하는게 부담이 될 수 있다.
그래서 다음과 같이 별도의 클래스를 작성하지 않고 인터페이스를 바로 구현하여 사용하는 방법이 있는데, 이것을 익명 구현 객체라고 부른다. (작성하지 않지만 별도의 클래스 파일이 생성 된다. 본문의 아래쪽에서 자세히 다룬다.)
package me.xxxelppa.study.week15;
public class Exam_002 {
public static void main(String[] args) {
AnonymousTest_002 anonymousTest_002 = new AnonymousTest_002() {
@Override
public void doSomething() {
System.out.println("do something !!");
}
};
anonymousTest_002.doSomething();
}
}
interface AnonymousTest_002 {
void doSomething();
}
do something !!
람다식 얘기에 앞서 굳이 익명 구현 객체에 대해 언급한 이유는 람다식이 이 익명 구현 객체와 생긴게 비슷하기 때문이다.
그래서 간혹 람다식이 간결하게 표현한 익명 구현 객체 처럼 생각 되지만 실제로 생성되는 코드를 보면 전혀 다르다는 것을 알 수 있다.
자세한 내용은 뒤쪽에 정리해 두었다.
함수형 인터페이스
람다식의 사용 방법에 정리하기에 앞서 함수형 인터페이스에 대해서 알아야 한다.
말이 거창해서 그렇지 결론부터 얘기하면 추상 메소드가 하나뿐인 인터페이스를 함수형 인터페이스라고 한다.
즉, 다음과 같은 인터페이스들을 모두 함수형 인터페이스에 속한다.
package me.xxxelppa.study.week15;
public class Exam_003 {
}
interface FunctionalInterface_001 {
void aaa();
}
// 함수형 인터페이스는 @FunctionalInterface 를 사용해서 명시적으로 컴파일러에게 알려줄 수 있다.
// @FunctionalInterface 를 사용하면 다른 사람이 추상 메소드를 추가하는 상황을 예방할 수 있다.
@FunctionalInterface
interface FunctionalInterface_002 {
void aaa();
// static 메소드가 있어도 괜찮다.
static void bbb() { }
// default 메소드가 있어도 괜찮다.
default void ccc() { }
}
위에 선언한 두 개의 인터페이스의 특징은, 이 인터페이스를 상속 받은 클래스가 반드시 구현해야하는 추상 메소드가 한 개 뿐이라는 것이다.
추가로 오버라이드 한 메소드에 대해 @Override 애노테이션을 붙여주는 것을 기억 할 것이다.
이 애노테이션은 작성하지 않아도 동작하는데 문제가 없지만
붙여 줌으로 컴파일 타임에 Override 된 것이 맞는지 확인하는 것과, 코드를 읽는 입장에서 재정의 된 메소드임을 인지할 수 있다.
비슷한 맥락에서 함수형 인터페이스도 @FunctionalInterface 라는 애노테이션을 붙여줄 수 있다.
이 애노테이션을 붙이면 컴파일러가 해당 인터페이스에 추상 메소드가 1개만 선언 되었는지 확인해주며
다른 사람이 이 코드를 보고 추상 메소드를 추가하는 문제를 예방할 수 있다.
만약 이 애노테이션이 작성 되어있는데, 복수개의 추상 메소드가 정의되어 있다면 IntelliJ IDE 기준으로 다음와 같은 메시지를 보여준다.
"Multiple non-overriding abstract methods found in interface me.xxxelppa.study.week15.FunctionalInterface_002"
람다식 사용법
람다식은 생략 가능한 부분이 꽤 많기 때문에 헷갈릴 수 있지만, 기본적으로 화살표를 기준으로 좌측에 매개변수가 우측에 실행 코드가 작성 된다는 것을 기억하면 된다.
(매개변수) -> { 실행 코드 }
위의 형태를 기준으로 String 타입의 문자열을 받아서 그대로 출력하는 경우를 생각해보자.
package me.xxxelppa.study.week15;
public class Exam_004 {
public static void main(String[] args) {
// 1. 작성 가능한 모든 내용을 생략 없이 작성한 경우
LambdaTest_001 myTest_01 = (String param) -> {
System.out.println(param);
};
// 2. 매개변수의 타입을 생략한 경우
LambdaTest_001 myTest_02 = (param) -> {
System.out.println(param);
};
// 3. 매개변수가 한개여서 소괄호를 생략한 경우
LambdaTest_001 myTest_03 = param -> {
System.out.println(param);
};
// 4. 실행 코드가 한 줄이어서 중괄호를 생략한 경우
LambdaTest_001 myTest_04 = (String param) -> System.out.println(param);
// 5. 매개변수가 한개이고 실행 코드가 한 줄이어서 생략 가능한 모든것을 생략한 경우
LambdaTest_001 myTest_05 = param -> System.out.println(param);
// 6. 반환값이 있는 경우 return 키워드 사용하는 경우
LambdaTest_002 myTest_06 = (n1, n2) -> {
System.out.println("6. 반환값이 있는 경우 return 키워드 사용하는 경우");
return n1 + n2;
};
// 7. 실행 코드가 반환 코드만 존재하는 경우 키워드와 중괄호 생략한 경우
LambdaTest_002 myTest_07 = (n1, n2) -> n1 + n2;
// 8. 매개변수가 없어서 소괄호를 생략할 수 없는 경우
LambdaTest_003 myTest_08 = () -> System.out.println("8. 매개변수가 없어서 소괄호를 생략할 수 없는 경우");
myTest_01.printString("1. 작성 가능한 모든 내용을 생략 없이 작성한 경우");
myTest_02.printString("2. 매개변수의 타입을 생략한 경우");
myTest_03.printString("3. 매개변수가 한개여서 소괄호를 생략한 경우");
myTest_04.printString("4. 실행 코드가 한 줄이어서 중괄호를 생략한 경우");
myTest_05.printString("5. 매개변수가 한개이고 실행 코드가 한 줄이어서 생략 가능한 모든것을 생략한 경우");
System.out.println();
System.out.println(myTest_06.add(10, 20));
System.out.println(myTest_07.add(20, 30));
myTest_08.noArgs();
}
}
@FunctionalInterface
interface LambdaTest_001 {
void printString(String str);
}
@FunctionalInterface
interface LambdaTest_002 {
int add(int num1, int num2);
}
@FunctionalInterface
interface LambdaTest_003 {
void noArgs();
}
1. 작성 가능한 모든 내용을 생략 없이 작성한 경우 2. 매개변수의 타입을 생략한 경우 3. 매개변수가 한개여서 소괄호를 생략한 경우 4. 실행 코드가 한 줄이어서 중괄호를 생략한 경우 5. 매개변수가 한개이고 실행 코드가 한 줄이어서 생략 가능한 모든것을 생략한 경우 6. 반환값이 있는 경우 return 키워드 사용하는 경우 30 50 8. 매개변수가 없어서 소괄호를 생략할 수 없는 경우
람다식을 사용할 때 타겟 타입 이라는 것이 존재한다.
타겟 타입이란, 람다식은 기본적으로 '인터페이스 변수'에 담기는데, 이 람다식이 담기는 인터페이스를 타겟 타입이라고 한다.
즉, 위에 작성한 예제 코드 기준으로 LambdaTest_001 과 LambdaTest_002 를 타겟 타입이라고 할 수 있다.
람다식을 사용할 때 타겟 타입이 중요한 이유는, 위에서 봐서 알겠지만, 람다식만 보고는 이게 어떤 함수형 인터페이스를 구현한 것인지 유추할 수 없기 때문이다.
() -> System.out.println("who am I .. ? ");
이것을 보고 void 타입의 매개변수가 없는 추상 메소드를 구현 했다는 것은 알 수 있지만
정확히 어떤 타겟 타입을 사용한 것인지(어떤 함수형 인터페이스를 사용 했는지) 모호하기 때문이다.
추가로 람다식 관련하여 자바 표준 API 가 존재한다.
필요한 경우 직접 함수형 인터페이스를 정의해서 사용해도 무관하지만, 자주 사용하는 형태에 대해 표준으로 정의해서 제공하고 있으니
특별한 경우(?)가 아니라면 직접 인터페이스를 정의해서 사용하는 일은 많지 않을 거라 생각한다.
아래는 처음 람다식을 공부했을 당시 정리했던 표이다.
(혹시 오타가 있다면 이 때 당시 전부 타이핑 했었기 때문이니 양해 부탁 드립니다.)
종류
추상메소드 특징
인터페이스명
추상메소드
andThen()
compose()
and() or() negate()
설명
Consumer
매개값은 있고, 리턴값은 없음
Consumer<T>
void accept(T t)
O
객체 T를 받아 소비
BiConsumer<T, U>
void accept(T t, U u)
O
객체 T와 U를 받아 소비
DoubleConsumer
void accept(double value)
O
double 값을 받아 소비
IntConsumer
void accept(int value)
O
int 값을 받아 소비
LongConsumer
void accept(long value)
O
long 값을 받아 소비
ObjDoubleConsumer
void accept(T t, double value)
객체 T와 double 값을 받아 소비
ObjIntConsumer
void accept(T t, int value)
객체 T와 int 값을 받아 소비
ObjLongConsumer
void accept(T t, long value)
객체 T와 long 값을 받아 소비
Supplier
매개값은 없고, 리턴값은 있음
Supplier<T>
T get()
T 객체를 리턴
BooleanSupplier
boolean getAsBoolean()
boolean 값을 리턴
DoubleSupplier
double getAsDouble()
double 값을 리턴
IntSupplier
int getAsInt()
int 값을 리턴
LongSupplier
long getAsLong()
long 값을 리턴
Function
매개값도 있고, 리턴값도 있음 주로 매개값을 리턴값으로 매핑 (타입변환)
Function<T, R>
R apply(T t)
O
O
객체 T를 객체 R로 매핑
BiFunction<T, U, R>
R apply(T t, U u)
O
객체 T와 U를 객체 R로 매핑
DoubleFunction<R>
R apply(double value)
double을 객체 R로 매핑
IntFunction<R>
R apply(int value)
int를 객체 R로 매핑
IntToDoubleFunction
double applyAsDouble(int value)
int를 double로 매핑
IntToLongFunction
long applyAsLong(int value)
int를 long으로 매핑
LongToDoubleFunction
double applyAsDouble(long value)
long을 double로 매핑
LongToIntFunction
int applyAsInt(long value)
long을 int로 매핑
ToDoubleBiFunction<T, U>
double applyAsDouble(T t, U u)
객체 T와 U를 double로 매핑
ToDoubleFunction<T>
double applyAsDouble(T t)
객체 T를 double로 매핑
ToIntBiFunction<T, U>
int applyAsInt(T t, U u)
객체 T와 U를 int로 매핑
ToIntFunction<T>
int applyAsInt(T t)
객체 T를 int로 매핑
ToLongBiFunction<T, U>
long applyAsLong(T t, U u)
객체 T와 U를 long으로 매핑
ToLongFunction<T>
long applyAsLong(T t)
객체 T를 long으로 매핑
Operator
매개값도 있고, 리턴값도 있음 주로 매개값을 연산하고 결과를 리턴
BinaryOperator<T>
BiFunction<T, U, R>의 하위 인터페이스
O
T와 U를 연산한 후 R리턴 두 개의 static method minBy(Comparator<? Super T> comparator) maxBy(Comparator<? Super T> comparator)
UnaryOperator<T>
Function<T, T>의 하위 인터페이스
T를 연산한 후 T리턴
DoubleBinaryOperator
double applyAsDouble(double, double)
두 개의 double 연산
DoubleUnaryOperator
double applyAsDouble(double)
O
O
한 개의 double 연산
IntBinaryOperator
int applyAsInt(int, int)
두 개의 int 연산
IntUnaryOperator
int applyAsInt(int)
O
O
한 개의 int 연산
LongBinaryOperator
long applyAsLong(long, long)
두 개의 long 연산
LongUnaryOperator
long applyAsLong(long)
O
O
한 개의 long 연산
Predicate
매개값은 있고, 리턴타입은 boolean 매개값을 조사해서 true/false를 리턴
Predicate<T>
boolean test(T t)
O
객체 T를 조사
BiPredicate<T, U>
boolean test(T t, U u)
O
객체 T와 U를 비교 조사
DoublePredicate
boolean test(double value)
O
double 값을 조사
IntPredicate
boolean test(int value)
O
int 값을 조사
LongPredicate
boolean test(long value)
O
long 값을 조사
(한빛미디어, 이것이 자바다. 신용권의 Java 프로그래밍 정복 2권 참고)
각 API 마다 모든 예제 코드를 작성하는 것을 나열하는 것은 별다른 의미가 없을 것 같다.
개인적으로 코드는 작성 하겠지만 여기에는 불필요하게 길어질 것 같아 생략 하려고 한다.
메소드, 생성자 레퍼런스에 대해 정리 하면서 Operator 와 Function에 대해 간접적으로 사용 예시를 작성할 예정이다.
728x90
Variable Capture
람다식의 실행 코드 블록 내에서 클래스의 멤버 필드와 멤버 메소드, 그리고 지역 변수를 사용할 수 있다.
클래스의 멤버 필드와 멤버 메소드는 특별한 제약 없이 사용 가능하지만, 지역변수를 사용함에 있어서는 제약이 존재한다.
이 내용을 잘 이해하기 위해서는 JVM 의 메모리에 대해 조금은 알아야 한다.
잠시 람다식이 아닌 다른 얘기를 해보자.
멤버 메소드 내부에서 클래스의 객체를 생성해서 사용 할 경우 다음과 같은 문제가 있다.
익명 구현 객체를 포함해서 객체를 생성 할 경우 new 라는 키워드를 사용 한다.
이 키워드를 사용한다는 것은 동적 메모리 할당 영역(이하 heap)에 객체를 생성한다는 것을 의미한다.
이렇게 생성된 객체는 자신을 감싸고 있는 멤버 메소드의 실행이 끝난 이후에도 heap 영역에 존재하므로 사용할 수 있지만
이 멤버 메소드에 정의 된 매개변수나 지역 변수는 런타임 스택 영역(이하 stack)에 할당되어 메소드 실행이 끝나면 해당 영역에서 사라져 더 이상 사용할 수 없게 된다.
그렇기 때문에 멤버 메소드 내부에서 생성된 객체가 자신을 감싸고 있는 메소드의 매개변수나 지역변수를 사용하려 할 때 문제가 생길 수 있다.
조금 더 쉽게 처음부터 설명 하면
1. 클래스의 멤버 메소드의 매개변수와 이 메소드 실행 블록 내부의 지역 변수는 JVM의 stack에 생성되고
메소드 실행이 끝나면 stack에서 사라진다.
2. new 연산자를 사용해서 생성한 객체는 JVM의 heap 영역에 객체가 생성되고 GC (Garbage Collector)에 의해 관리되며, 더 이상 사용하지 않는 객체에 대해 필요한 경우 메모리에서 제거한다.
heap에 생성된 객체가 stack의 변수를 사용하려고 하는데, 사용하려는 시점에 stack에 더 이상 해당 변수가 존재하지 않을 수 있다. 왜냐하면 stack은 메소드 실행이 끝나면 매개변수나 지역변수에 대해 제거하기 때문이다. 그래서 더 이상 존재하지 않는 변수를 사용하려 할 수 있기 때문에 오류가 발생한다.
자바는 이 문제를 Variable Capture 라고 하는 값 복사를 사용해서 해결하고 있다.
즉, 컴파일 시점에 멤버 메소드의 매개변수나 지역 변수를 멤버 메소드 내부에서 생성한 객체가 사용 할 경우 객체 내부로 값을 복사해서 사용한다. 하지만 모든 값을 복사해서 사용할 수 있는 것은 아니다.
여기에도 제약이 존재하는데 final 키워드로 작성 되었거나 final 성격을 가져야 한다.
final 키워드로 작성 되는 것은 알겠는데, 성격을 가진다는 것은 무엇일까.
final 성격을 가진다는 것은 final 키워드로 선언된 것은 아니지만 값이 한 번만 할당 되어 final 처럼 쓰이는 것을 뜻한다.
(java 1.7 까지는 final 을 반드시 명시 했어야 했고, final 을 생략하고 쓸 수 있는 건 java 1.8 부터로 기억한다.)
final 키워드 유무에 따라 값이 복사 되는 위치가 달라지는데, 다음과 같이 복사 된다.
package me.xxxelppa.study.week15;
public class Exam_005 {
public void testMethod(final String myFinalString_01, String myString_01) {
final String myFinalString_02 = "myFinalString_02";
String myString_02 = "myString_02";
class VariableCaptureTest {
// final 명시하지 않은 경우 멤버 필드로 복사
// String myString_01
// String myString_02
void print() {
// final 명시한 경우 지역 변수로 복사
// String myFinalString_01
// String myFinalString_02
// System.out.println(myFinalString_01 + " :: " + myFinalString_02);
// System.out.println(myString_01 + " :: " + myString_02);
}
}
new VariableCaptureTest().print();
}
public static void main(String[] args) {
Exam_005 exam_005 = new Exam_005();
exam_005.testMethod("myFinalString_01", "myString_01");
}
}
09 ~11 라인, 13 ~ 15 라인은 컴파일 시 생성 될 것으로 예상한 내용을 정리한 것이다.
호기심이 생겨서 16, 17 라인을 사용 할 때와 안 할 때 생성되는 바이트 코드를 비교해 보았다.
당장 눈에 띄는 것은 중첩 클래스 때문에 '외부_클래스'${숫자}'내부_클래스'.class 클래스 파일이 추가로 생성 되었다.
비교 결과 값 복사를 볼 수 확인할 수 있었는데, 예상했던 것과 조금 다른 결과가 나왔다.
1. final 키워드를 사용한 매개변수 : 값 복사가 일어남
2. final 키워드를 사용한 지역변수 : final 키워드가 사라지고 값 복사가 일어나지 않음
3. final 키워드를 사용하지 않은 매개변수 : final 키워드가 생성되고 값 복사가 일어남
4. final 키워드를 사용하지 않은 지역변수 : final 키워드가 생성되고 값 복사가 일어남
결과가 조금 이상한 것 같아서 디컴파일 된 결과가 아닌 바이트 코드를 열어 보았다.
// class version 55.0 (55) // access flags 0x21 public class me/xxxelppa/study/week15/Exam_005 {
복잡하다면 익명 구현 객체를 사용 할 때와 람다식을 사용했을 때 다음과 같은 차이점이 있다는 것만 기억하면 된다.
1. 람다식은 익명 구현 객체 처럼 별도의 객체를 생성하거나 컴파일 결과 별도의 클래스를 생성하지 않는다는 것
2. 람다식 내부에서 사용하는 변수는 Variable Capture(값 복사)가 발생하며, 이 값은 final이거나 final 처럼 사용해야 한다는 것
사실 사용하는 입장에서는 '인스턴스 변수를 제외하고 람다식 내부에서 사용하는 변수는 final이거나 final 성격을 가져야 한다' 고 기억하면 별 문제 없긴 하다.
메소드, 생성자 레퍼런스
메소드, 생성자 레퍼런스는 람다식을 더 간략하게 표현할 수 있게 해준다..
콜론 두 개 :: 를 사용하며, 크게 다음과 같이 구분할 수 있다.
1. static 메소드 참조
=> 클래스_이름::메소드_이름
2. 인스턴스 메소드 참조
=> 인스턴스_변수::메소드_이름
3. 람다식의 매개변수로 접근 가능한 메소드 참조
=> 매개변수의_타입_클래스_이름::메소드_이름
4. 생성자 참조
=> 클래스_이름::new
로 사용할 수 있다.
각 경우에 대한 간단한 예시를 작성해 보았다.
우선 1, 2, 3번에 해당하는 예제는 다음과 같다.
package me.xxxelppa.study.week15;
import java.util.function.BiFunction;
import java.util.function.IntBinaryOperator;
import java.util.function.ToIntBiFunction;
public class Exam_006 {
public static void main(String[] args) {
// int 타입 두 개를 받아 int 타입을 반환하는 표준 api 사용
IntBinaryOperator op;
// static method 참조
op = (num_01, num_02) -> MyReference.add_static(num_01, num_02);
System.out.println(op.applyAsInt(10, 20));
op = MyReference::add_static;
System.out.println(op.applyAsInt(20, 30));
// instance method 잠조
MyReference mr = new MyReference();
op = (num_01, num_02) -> mr.add_instance(num_01, num_02);
System.out.println(op.applyAsInt(30, 40));
op = mr::add_instance;
System.out.println(op.applyAsInt(40, 50));
// 람다식의 매개변수로 접근 가능한 메소드 참조
//
// 만약 (x, y) -> x.instanceMethod(y) 인 경우가 있는데
// 이런 경우 사용할 수 있는 방법은 아래와 같다.
//
// 아래 코드는 x 문자열에 y 문자열이 포함되어 있는지 결과를 반환하는 예제이다.
// String 클래스의 contains 를 사용 한다.
//
// 이 경우 static method 참조와 형태가 매우 유사해 보이지만
// x의 타입에 속하는 클래스 다음에 :: 연산자를 사용해서 메소드 참조를 한다.
BiFunction<String, String, Boolean> myBiFunction;
myBiFunction = (x, y) -> x.contains(y);
System.out.println(myBiFunction.apply("java study", "java"));
myBiFunction = String::contains;
System.out.println(myBiFunction.apply("java online study", "python"));
}
}
class MyReference {
// static method
public static int add_static(int num_1, int num_2) {
return num_1 + num_2;
}
// instance method
public int add_instance(int num_1, int num_2) {
return num_1 + num_2;
}
}
30 50 70 90 true false
다음은 생성자 참조의 예제이다.
package me.xxxelppa.study.week15;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;
public class Exam_007 {
public static void main(String[] args) {
BiFunction<String, Integer, ConstructorRefTest> bf;
Function<String, ConstructorRefTest> f;
Supplier<ConstructorRefTest> s;
bf = (param_1, param_2) -> new ConstructorRefTest(param_1, param_2);
System.out.println(bf.apply("nimkoes", 17).toString());
System.out.println();
s = ConstructorRefTest::new;
System.out.println("기본 생성자 : " + s.get().toString());
f = ConstructorRefTest::new;
System.out.println("String 하나를 받는 생성자 : " + f.apply("nimkoes").toString());
bf = ConstructorRefTest::new;
System.out.println("String, int 두 개를 받는 생성자 : " + bf.apply("xxxelppa", 71).toString());
}
}
class ConstructorRefTest {
String name;
int age;
public ConstructorRefTest() {
}
public ConstructorRefTest(String name) {
this.name = name;
}
public ConstructorRefTest(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "ConstructorRefTest{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
ConstructorRefTest{name='nimkoes', age=17}
기본 생성자 : ConstructorRefTest{name='null', age=0} String 하나를 받는 생성자 : ConstructorRefTest{name='nimkoes', age=0} String, int 두 개를 받는 생성자 : ConstructorRefTest{name='xxxelppa', age=71}
마지막으로 함수형 프로그래밍에 대해서 생각해보려 한다.
1급 시민 또는 1급 객체 (First-class citizen)
다음의 세 가지 조건을 모두 만족하는 것들을 말한다.
1. 변수에 할당할 수 있다.
2. 매개변수로 사용할 수 있다.
3. 반환 값으로 사용할 수 있다.
당장 생각나는 대표적인 예는 javascript 의 function 이다.
function myFunc() {
console.log("문자열을 출력 합니다.");
}
(function myMainFunc(param) {
// 매개변수로 사용 가능
console.log("매개변수로 전달 된 함수 실행");
param();
console.log("");
// 변수에 할당 가능
let myVal = param;
console.log("변수에 담긴 함수 실행");
myVal();
console.log("");
let returnVal = (function () { return param; })();
console.log("반환 결과 변수에 담긴 함수 실행");
returnVal();
console.log("");
})(myFunc);
갑자기 javascript 코드가 나와서 좀 그렇지만
javascript 에서 함수는 변수에 담을 수 있고, 매개변수로도 전달 가능하며, 반환 값으로도 사용할 수 있다.
그렇기 때문에 javascript 에서 함수는 유명한 1급 시민 객체이다.
다시 자바 얘기를 해보자.
혹시 지금까지 자바 코드를 작성 하면서 메소드를 변수에 담아 전달해본 적이 있었을까?
메소드는 클래스에 종속되어 객체로 전달 하거나 객체를 반환한 적은 있어도, 메소드 자체를 전달해본 적은 없다.
그래서 자바의 메소드는 1급 시민 객체가 아니다.
하지만 자바의 람다식은 변수에 담을 수 있고, 매개변수로 전달할 수 있으며, 반환 값으로 사용할 수 있다.
package me.xxxelppa.study.week15;
public class Exam_009 {
public static void main(String[] args) {
// 변수에 저장
MyInterface mi = () -> System.out.println("변수에 저장 된 람다식");
mi.print();
// 매개변수로 전달
doProc(() -> System.out.println("매개변수로 전달 된 람다식"));
// 반환 값으로 사용
getProc().print();
}
public static void doProc(MyInterface mi) {
mi.print();
}
public static MyInterface getProc() {
return () -> System.out.println("반환 값으로 사용 된 람다식");
}
}
interface MyInterface {
void print();
}
변수에 저장 된 람다식 매개변수로 전달 된 람다식 반환 값으로 사용 된 람다식
람다식이 세가지 조건을 만족하기 때문에 1급 시민 객체인 것은 알겠는데, 그래서 뭐가 좋다는 걸까
앞서 람다식의 Variable Capture 에 대해 정리 하면서 언급한 내용을 기억해야 한다.
heap 영역에 생성된 객체가 stack 영역의 변수를 안정적으로 사용하기 위해 final 또는 final 성격을 가져야 한다.
즉, 변할 수 있는 것을 변하지 않도록 제한을 둔 것이다. 이것을 불변 상태(Immutable)로 만든다고 한다.
불변 상태로 만든다는 것에 대해 잘 이해가 되지 않는다면, 조금 더 쉬운 말로 '외부의 상태에 독립적'이라고 표현할 수 있다.
외부의 상태에 독립적이라는 것은 다른 말로 순수 함수라고 할 수 있는데, 다음의 javascript 코드를 보자.
let gender = 'male';
function myPureFunction (param){
return "성별 : " + param;
}
function myFunction (param) {
let result = "성별 : ";
if(param === 'male') {
result += "남성";
} else {
result += "여성";
}
return result;
}
(function(){
console.log(myPureFunction("남성"));
console.log(myFunction(gender));
})();
실행 결과는 같지만, myPureFunction 과 myFunction 의 차이점은 실행 결과가 외부 변수에 의해 실행 결과에 영향을 주는가 이다.
Variable Capture 를 통해 람다식 내부에서 사용하는 지역 변수에 대해 final 이어야 하는 이유도 같은 맥락이라고 할 수 있다.
불변 상태로 만들면 지역 변수에 대해 변하지 않는 상수를 사용하기 때문에 동일한 입력에 대해 동일한 결과를 기대할 수 있다.
이것을 부작용(side effect, 부수효과) 이 없다고 한다.
동일한 입력에 대해 일관된 결과를 받아볼 수 있다는 것은 다시 말하면
다수의 쓰레드가 동시에 공유 해서 사용한다고 하더라도 일관된 결과를 받아볼 수 있다는 것으로
제네릭을 사용하는 이유에는 흔히 알고있는 컴파일 타임에 타입 체크를 하기 위함이나 타입 캐스팅을 제거하여 프로그램 성능 향상을 위해서 이다.
하지만 보다 궁극적인(?) 목적은 중복코드의 제거에 있다고 생각 한다.
예를 들어 다음과 같이 List 에 담긴 내용을 모두 출력하는 메소드를 구현한다고 생각해보자.
list 에 담긴 요소의 타입이 정수인 경우 다음과 같이 만들어볼 수 있다.
public static void printAllIntegers(List list) {
for(int i = 0; i < list.size(); ++i) {
System.out.println((int)list.get(i));
}
}
list 에 담긴 요소의 타입이 문자열인 경우 다음과 같이 만들어볼 수 있다.
public static void printAllStrings(List list) {
for(int i = 0; i < list.size(); ++i) {
System.out.println((String)list.get(i));
}
}
각 메소드의 구현부를 비교해보면 타입 캐스팅 부분을 제외하고 모두 동일하다.
심지어 만약 매개변수로 전달 받은 list 에 담긴 요소에 기대하지 않은 타입의 요소가 담겨 있다면 런타임에 예외가 발생할 것이다.
***
첨언이지만, List 클래스의 경우 제네릭을 지원한다. 하지만 위에 작성한 예제는 제네릭을 사용하지 않고 구현 했고 실제로도 동작하는 것을 확인할 수 있다.
이렇게 제네릭을 지원 하지만 제네릭을 사용하지 않고 클래스 자체를 사용하는 경우 raw type (로 타입)을 사용 했다고 한다.
앞으로 정리 하겠지만 로 타입은 되도록 사용하지 말아야 한다.
사용할 경우 앞서 제네릭의 강력한 장점인 컴파일 타임에 타입 체크를 하는 것과 타입 캐스팅을 하지 않음으로 얻는 이득을 얻지 못하기 때문이다.
그럼 왜 자바를 만든 사람들은 제네릭을 만들었으면서 사용을 강제하지 않고 로 타입도 지원하도록 했을까.
그건 아마도 자바 언어 개발자들이 하위 호환성을 유지하기 위해서다.
제네릭은 JDK5 에 추가된 개념인데 사용을 강제하도록 하면 이전 버전에서는 사용할 수 없기 때문이다.
마지막으로 살펴볼 Erasure 도 하위 호환성을 유지하기 위한 내용이다.
***
위에 작성한 코드를 개선하는 방법에 대해서는 '제네릭 메소드 만들기'에서 자세히 정리하고, 우선 사용 방법에 대해 알아보자.
제네릭 사용법
제네릭은 클래스, 인터페이스 그리고 메소드에 사용할 수 있다. 이 때 중요한 것은 매개변수로 '타입'을 전달할 수 있다는 것이다.
이것을 흔히 타입 파라미터 라고 부르고 타입 파라미터의 타입을 작성을 할 때는 각괄호 <> 를 이용한다. 다이아몬드
말로 설명하려니 어려운것 같다. 문자열을 요소로 가지는 리스트를 정의하고 값을 출력하는 예를 살펴보자.
package me.xxxelppa.study.week14;
import java.util.ArrayList;
import java.util.List;
public class Exam_002 {
public static void main(String[] args) {
List without_generic = new ArrayList();
without_generic.add("제네릭을 사용하지 않은 로타입 리스트");
List<String> with_generic = new ArrayList<>();
with_generic.add("제네릭을 사용한 리스트");
/*
객체를 생성할 때 아래와 같이 구체적인 타입을 작성 해야 하지만 생략 가능하다.
List<String> with_generic = new ArrayList<String>();
이렇게 제네릭을 사용할 때 구체적인 타입 생략이 가능한 것을 다이아몬드 연산자 라고도 한다.
*/
// 출력
String without_generic_String = (String)without_generic.get(0); // 타입 캐스팅이 필요하다.
String with_generic_String = with_generic.get(0); // 타입 캐스팅이 필요하지 않다.
}
}
제네릭을 사용 한다고 하면 위와 같인 각괄호 안에 사용 할 타입을 작성해서 사용하면 된다.
이번엔 컴파일 타임에 타입을 체크 한다는 것이 무엇인지 알아보자.
만약 타입 파라미터와 맞지 않는 타입의 값을 사용하려 한다면 다음과 같은 메시지를 보여준다.
기억해야 하는 것은 '컴파일 타임'에 타입 체크를 한다는 것이다.
제네릭을 사용하는 것은 알겠는데, 어떻게 만들어져 있길래 사용할 수 있는 건지에 대해서는 얘기하지 않았다.
직접 제네릭을 사용한 클래스를 만들어 보자.
다음은 타입 매개변수 타입의 값을 하나 저장할 수 있는 클래스를 선언하고 사용한 예제이다.
package me.xxxelppa.study.week14;
public class Exam_004 {
// T 타입 value 를 저장할 수 있는 클래스
static class MyGenericClass<T> {
T value;
MyGenericClass(T value) {
this.value = value;
}
public T getValue() {
return value;
}
public void setValue(T value) {
this.value = value;
}
}
public static void main(String[] args) {
MyGenericClass<String> mgc_String = new MyGenericClass<>("사과");
System.out.println(mgc_String.getValue());
mgc_String.setValue("자몽");
System.out.println(mgc_String.getValue());
}
}
사과 자몽
처음 보면 다소 생소할 수 있지만, 차분히 보면 그리 어렵지 않다.
6라인에서 MyGeneric 클래스를 보면 지금까지 정의했던 클래스와 다른점은 <T> 라는 부분이 추가된 것이다.
이 내용을 작성 함으로 인해 MyGeneric 이라는 클래스 내부에서 T 라는 것을 사용할 수 있게 된다.
그리고 이 T 는 이 클래스 내부에서 '타입 매개변수'를 대표하는 값으로 사용 된다.
그럼 왜 하필 T 일까?
사실 어떤 문자를 사용해도 상관 없다.
심지어 <myTypeParameter> 라고 해도 잘 동작한다.
물론 이렇게 정의 했을 경우, 너무나 당연하게도 , 위에서 T 라고 쓴 부분을 모두 myTypeParameter 라고 고쳐줘야 한다.
그럼에도 불구하고 T 를 사용한 이유는 흔히 컨벤션 이라고 하는 우리들 사이의 약속이기 때문이다.
되도록 컨벤션을 지켜 코드를 작성하는게 서로 이해하기 쉽기 때문에 특별한 경우가 아니라면 지켜주는게 좋다.
만약 타입 파라미터가 두 개 이상일 경우 T 이외에 R, S, E, K, V, N, U 등의 문자를 많이 사용 하고 콤마(,) 로 구분한다.
보통 T(type), R(return type), S(String), E(element), K (key), V(value), N(number) 의 의미로 사용하는 것으로 알고있다.
package me.xxxelppa.study.week14;
public class Exam_005 {
static class MyGenericClass<T, M> {
T value_1;
M value_2;
public T getValue_1() {
return value_1;
}
public void setValue_1(T value_1) {
this.value_1 = value_1;
}
public M getValue_2() {
return value_2;
}
public void setValue_2(M value_2) {
this.value_2 = value_2;
}
}
public static void main(String[] args) {
MyGenericClass<String, Integer> mgc = new MyGenericClass<>();
mgc.setValue_1("사과");
mgc.setValue_2(1000);
System.out.println(mgc.getValue_1() + " 한 개 " + mgc.getValue_2() + "원");
}
}
사과 한 개 1000원
728x90
제네릭 메소드 만들기
순서대로 라면 '제네릭 주요 개념' 을 먼저 정리 해야 하지만, 편의상 메소드 만드는 것을 먼저 정리하게 되었다.
앞서 제네릭은 클래스와 인터페이스 그리고 메소드에서 사용할 수 있다고 했는데, 이번엔 메소드에서 사용하는 방법을 알아보려 한다.
제네릭 메소드에 대해 정의 하자면, 파라미터와 반환 타입으로 제네릭 타입을 사용하는 것을 말한다.
다음은 타입 파라미터를 하나 전달 받아 그 값을 그대로 반환하는 코드이다.
package me.xxxelppa.study.week14;
public class Exam_006 {
public static <T> T myGenericTest(T t) {
return t;
}
public static void main(String[] args) {
System.out.println(myGenericTest("자몽"));
System.out.println(myGenericTest(1500));
}
}
자몽 1500
메소드에서 사용할 경우 클래스에서 사용 할때와 다른 부분이 있다.
앞서 클래스에서 사용할 예시에서 getter, setter 에서는 볼 수 없었던 5 라인에 작성된 <T> 가 그렇다.
제네릭 클래스에서는 해당 클래스 내부에서 사용 할 타입 파라미터가 무엇인지 알려주기 위해 class 를 선언할 때 알려 주었다면
제네릭 메소드에서는 메소드를 정의할 때, 해당 메소드 내부에서 사용 할 타입 파라미터가 무엇인지 알려주기 위해
메소드를 정의할 때 5라인 처럼 먼저 나열을 해주고 사용해야 한다. 즉, 리턴 타입을 명시하기 전에 작성 되어야 한다.
마치 '이제부터 T 라는 문자를 만나면, 그건 타입 파라미터니까 놀라지 마' 라고 알려주는 것과 같다.
이번엔 처음에 작성했던 List 내용을 모두 출력하던 메소드를 개선해보자.
개선하기 전의 모습은 다음과 같다.
package me.xxxelppa.study.week14;
import java.util.ArrayList;
import java.util.List;
public class Exam_001 {
public static void printAllIntegers(List list) {
for(int i = 0; i < list.size(); ++i) {
System.out.println((int)list.get(i));
}
}
public static void printAllStrings(List list) {
for(int i = 0; i < list.size(); ++i) {
System.out.println((String)list.get(i));
}
}
public static void main(String[] args) {
List myList_Integers = new ArrayList();
myList_Integers.add(1);
myList_Integers.add(2);
myList_Integers.add(3);
List myList_Strings = new ArrayList();
myList_Strings.add("가");
myList_Strings.add("나");
myList_Strings.add("다");
printAllIntegers(myList_Integers);
printAllStrings(myList_Strings);
}
}
1 2 3 가 나 다
이것을 제네릭 메소드로 구현하면 다음과 같이 작성해볼 수 있다.
package me.xxxelppa.study.week14;
import java.util.ArrayList;
import java.util.List;
public class Exam_007 {
public static <T> void printAll(List<T> list) {
for(T t : list) {
System.out.println(t);
}
}
public static void main(String[] args) {
List<Integer> myList_Integers = new ArrayList<>();
myList_Integers.add(1);
myList_Integers.add(2);
myList_Integers.add(3);
List<String> myList_Strings = new ArrayList<>();
myList_Strings.add("가");
myList_Strings.add("나");
myList_Strings.add("다");
printAll(myList_Integers);
printAll(myList_Strings);
}
}
실행 해보면 앞서 작성한 것과 동일한 결과가 나오는 것을 확인할 수 있다.
8라인에 작성한 printAll 제네릭 메소드를 보면,
앞서 List 에 담긴 요소의 타입에 따라 서로 다른 메소드를 작성 하고
요소의 값을 사용할 경우 각 타입에 맞게 타입 캐스팅을 해주어야 했는데
제네릭을 사용해서 컴파일 타임에 요소의 타입 검사를 통해 런타임 오류를 방지하면서
타입 캐스팅도 하지 않고
보다 범용적인 메소드를 작성할 수 있는 것을 볼 수 있다.
이쯤이면 제네릭이 굉장히 매력적이라고(?) 생각할 수 있을것 같다.
이정도만 해도 충분히 훌륭해 보이지만 사실 문제가 남아있다.
물론 내가 생각한건 아니고 수많은 선배 개발자 분들은 이미 알고 있었고 또 해결까지 해두셨으니 걱정할건 없다.
제네릭 주요 개념(바운디드 타입, 와일드 카드)
바로 위에서 언급한 남아있는 문제를 해결하기 위한 방법이 바운디드 타입과 와일드 카드이다.
리스트의 모든 요소를 출력하는 예제를 조금 수정해서 다음과 같이 수정해 보았다.
다음은 리스트에 담긴 요소를 문자열이라 가정하고, 공백을 기준으로 나누어 그 개수가 몇개인지 출력하는 메소드이다.
package me.xxxelppa.study.week14;
import java.util.ArrayList;
import java.util.List;
public class Exam_008 {
public static <T> List<Integer> printTokenSizeList(List<T> list) {
List<Integer> result = new ArrayList<>();
for(T t : list) {
result.add(((String)t).split(" ").length);
}
return result;
}
public static void main(String[] args) {
List<String> myList = new ArrayList<>();
myList.add("가을 하늘 공활한데 높고 구름 없이");
myList.add("밝은 달은 우리 가슴 일편단심일세");
myList.add("무궁화 삼천리 화려 강산");
myList.add("대한사람 대한으로 길이 보전하세");
List<Integer> result = printTokenSizeList(myList);
for(int elem : result) {
System.out.println(elem);
}
}
}
6 5 4 4
별다른 문제 없이 의도한 대로 동작하는것 같지만 사실 불편한 부분이 몇가지 있다.
우선 11라인에서 String 타입으로 캐스팅 하는 코드가 있다는 것이다.
만약 이 메소드의 매개변수로 다음과 같은 List 가 전달되면 어떻게 될까?
List<Integer> myNewList = new ArrayList<>();
myNewList.add(1);
myNewList.add(2);
myNewList.add(3);
컴파일 오류는 발생하지 않지만 런타임 에러가 발생한다.
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
제네릭을 사용해서 컴파일 타임에 타입 체크도 해주고, 코드 재사용성도 좋아졌다고 했는데 신통치 않다.
이 문제를 해결 하기 위한 개념이 바운디드 타입이다. 이 개념을 사용하면 타입 파라미터의 타입을 제한할 수 있다.
쉽게 얘기하면 타입 파라미터로 사용할 수 있는 타입을 특정 타입으로 제한할 수 있다는 것이다.
사용하는 방법은 extends 키워드를 사용해서 다음과 같이 작성해주면 된다.
다음은 위에 작성한 예제에서 타입 파라미터 T의 값을 String 클래스로 제한하도록 수정한 코드이다.
package me.xxxelppa.study.week14;
import java.util.ArrayList;
import java.util.List;
public class Exam_008 {
public static <T extends String> List<Integer> printTokenSizeList(List<T> list) {
List<Integer> result = new ArrayList<>();
for(T t : list) {
result.add(t.split(" ").length);
}
return result;
}
public static void main(String[] args) {
List<String> myList = new ArrayList<>();
myList.add("가을 하늘 공활한데 높고 구름 없이");
myList.add("밝은 달은 우리 가슴 일편단심일세");
myList.add("무궁화 삼천리 화려 강산");
myList.add("대한사람 대한으로 길이 보전하세");
List<Integer> result = printTokenSizeList(myList);
for(int elem : result) {
System.out.println(elem);
}
}
}
수정한 부분은 8라인에서 타입 파라미터의 타입을 String으로 제한한 부분과
그렇기 때문에 11라인에서 T 타입의 변수 t 에 대해 String 타입이라는 것을 알고 있기 때문에
더 이상 타입 캐스팅이 의미가 없어 삭제한 부분이다.
extends 를 사용해서 타입 제한을 하는 경우 제한한 타입을 포함한 해당 타입의 하위 타입들을 사용할 수 있다.
다음 예시는 List에 담긴 모든 요소 값의 합을 반환하는 예제이다.
package me.xxxelppa.study.week14;
import java.util.ArrayList;
import java.util.List;
public class Exam_010 {
public static <T extends Number> double getSum(List<T> list) {
double sum = 0.0;
for(T t : list) sum += t.doubleValue();
return sum;
}
public static void main(String[] args) {
List<Number> myNumber = new ArrayList<>();
myNumber.add(10);
myNumber.add(2.5);
System.out.println(getSum(myNumber));
}
}
12.5
이렇게 타입을 제한하는 방법중에 와일드 카드를 사용하는 방법이 있다.
와일드 카드는 보통 '모든 것' 을 뜻하는데 * (별표, asterisk) 또는 ? (물음표) 를 사용하는데, 자바 제네릭 에서는 ? 를 사용한다.
크게 세가지 형태가 존재한다.
1. <?>
: 모든 종류의 클래스나 인터페이스 타입 사용 가능
2. <? extends 상위타입>
: 상위타입 타입 또는 이 타입의 하위타입만 사용 가능
3. <? super 하위타입>
: 하위타입 타입 또는 이 타입의 상위타입만 사용 하능
이해를 돕기 위해 다음과 같은 상속 구조를 갖는 클래스들을 정의 했다고 가정하자.
package me.xxxelppa.study.week14;
import java.util.Arrays;
import java.util.List;
public class Exam_011 {
public static void main(String[] args) {
// List 의 요소 타입으로 제한을 두지 않음
List<?> wildcard_test = Arrays.asList(
new Root(),
new Sub_01(),
new Sub_02(),
new Sub_02_Sub(),
new Exam_011()
);
// List 의 요소 타입으로 Sub_02 또는 Sub_02 하위 타입으로 제한
List<? extends Sub_02> wildcard_extends_test = Arrays.asList(
new Sub_02(),
new Sub_02_Sub()
);
// List 의 요소 타입으로 Sub_01 또는 Sub_01 상위 타입으로 제한
List<? super Sub_01> wildcard_super_test = Arrays.asList(
new Root(),
new Sub_01()
);
wildcard_test.forEach(System.out::println);
System.out.println();
wildcard_extends_test.forEach(System.out::println);
System.out.println();
wildcard_super_test.forEach(System.out::println);
}
}
class Root {}
class Sub_01 extends Root {}
class Sub_02 extends Root {}
class Sub_02_Sub extends Sub_02 {}
재밌는? 것은 제네릭을 사용하지 않고 raw type 으로 ArrayList를 생성 해도 똑같은 바이트 코드를 볼 수 있다는 것이다.
그리고 내부에서 타입 파라미터를 사용할 경우 Object 타입으로 취급하여 처리 된다.
이것을 타입 소거 (type Erasure) 라고 한다.
타입 소거는 제네릭 타입이 특정 타입으로 제한 되어 있을 경우 해당 타입에 맞춰 컴파일시 타입 변경이 발생하고
타입 제한이 없을 경우 Object 타입으로 변경된다.
그럼 왜 이렇게 만들었을까? 그 이유는 하위 호환성을 지키기 위해서이다.
제네릭을 사용하더라도 하위 버전에서도 동일하게 동작해야하기 때문이다.
primitive 타입을 사용하지 못하는 것도 바로 이 기본 타입은 Object 클래스를 상속받고 있지 않기 때문이다.
그래서 기본 타입 자료형을 사용하기 위해서는 Wrapper 클래스를 사용해야 한다.
Wrapper 클래스를 사용할 경우 Boxing 과 Unboxing 을 명시적으로 사용할 수도 있지만 암묵적으로도 사용할 수 있으니 구현 자체에는 크게 신경쓸 부분은 없는것 같다.
마지막으로 제네릭과 관련하여 한가지 더 생각해볼 문제가 있다.
다음 코드는 제네릭 타입 파라미터를 사용해서 배열을 생성하는 예제이다.
package me.xxxelppa.study.week14;
import java.util.Arrays;
public class Exam_013<T> {
private T[] myArray;
Exam_013(int size) {
// myArray = new T[size]; // Type parameter 'T' cannot be instantiated directly
myArray = (T[]) new Object[size];
}
public void addElem(int index, T t) {
myArray[index] = t;
}
public void printElem() {
System.out.println(Arrays.toString(myArray));
}
public static void main(String[] args) {
Exam_013<String> e2 = new Exam_013<>(3);
e2.addElem(0, "java");
e2.addElem(1, "generic");
e2.printElem();
}
}
[java, generic, null]
제네릭 타입을 사용해서 배열을 생성하려면 9라인 처럼 쓰는게 편할텐데, 왜 사용하지 못하고 10 라인처럼 생성해야 하는걸까?
그 이유는 new 연산자를 사용하기 때문이다.
new 연산자는 동적 메모리 할당 영역인 heap 영역에 생성한 객체를 할당한다.
하지만 제네릭은 컴파일 타임에 동작하는 문법이다.
컴파일 타임에는 T의 타입이 어떤 타입인지 알 수 없기 때문에 Object 타입으로 생성한 다음 타입 캐스팅을 해주어야 사용할 수 있다.
연장선에서 static 변수에도 제네릭 타입을 사용할 수 없다.
package me.xxxelppa.study.week14;
public class Exam_014<T> {
private T myValue_1;
// 'me.xxxelppa.study.week14.Exam_014.this' cannot be referenced from a static context
// private static T myValue_2;
}
조금만 생각해보면 그 이유를 금방 알 수 있다.
static 키워드를 사용해서 멤버 필드를 선언하게 되면, 특정 객체에 종속되지 않고 클래스 이름으로 접근해서 사용할 수 있다.
제네릭 타입을 사용하면, 위 예제의 경우 Exam_014<String> 과 Exam_014<Integer> 등으로 객체를 생성해서
인스턴스마다 사용하는 타입을 다르게 사용할 수 있어야 하는데
static 으로 선언한 변수가 가능할 수가 없다. 그렇기 때문에 static 변수에는 제네릭 타입을 사용할 수 없다.
하지만 재미있게도 static 메소드에는 제네릭을 사용할 수 있다.
심지어 위에 작성한 예제에서도 static 메소드를 자유롭게 사용했다.
왜 static 변수에는 사용할 수 없었는데 메소드에는 가능했을까.
이것도 조금만 생각해보면 그 이유를 알 수 있다.
static 키워드를 사용하면 클래스 이름으로 접근하여 객체를 생성하지 않고 여러 인스턴스에서 공유해서 사용할 수 있다.
변수같은 경우 해당 값을 사용하려면 값의 타입을 알아야 하지만
메소드의 경우 해당 기능을 공유해서 사용하는 것이기 때문에 제네릭 타입 변수 T 를 매개변수로 사용한다고 하면
해당 값은 메소드 안에서 지역 변수로 사용되기 때문에 변수와 달리 메소드는 static 으로 선언 되어 있어도 제네릭을 사용할 수 있다.
이번엔 lombok.launch.AnnotationProcessorHider 클래스에 중첩클래스인 AnnotationProcessor 를 열어보자.
파일에 작성되어있던 AnnotationProcessorHider 클래스에 중첩 된 두 개의 클래스를 볼 수 있는데, 공통점이 눈에 띈다. 바로 AbstractProcessor 클래스를 상속 받고 있다.
결론부터 얘기하기엔 늦었지만, 어쨌든 결론은, Annotation Processor 를 등록하려면
1. META-INF 디렉토리 하위에 services 디렉토리를 만들고 2. javax.annotation.processing.Processor 라는 파일을 만들고 3. 그 안에 AbstractProcessor 클래스를 상속받아 구현하고 있는 클래스의 FQCN 을 작성 하면 된다.
그러면 이제 컴파일러는 이 정보를 바탕으로 annotation processor 기능을 수행할 수 있게 된다.
더 깊게 찾아본다면, 예상하건데, annotation 을 스캔해서 롬복에서 제공하는 애노테이션이 있으면 (예를 들어 Getter, Setter) 바이트 코드를 컴파일 타임에 자동 생성해주는 부분을 찾을 수 있을것 같다.
마지막으로 이 annotation processor 의 장점이라고 하면, 이러한 동작이 '컴파일 타임'에 이루어지기 때문에 런타임에 비용이 추가되지 않는다는 것이다.
하지만 단점은 (내가 알기로) 공식 지원하는 기능이 아니기 때문에, 어떻게 보면 편법?이나 해킹?이라고 할 수 있다는 것이다. 또 다른 단점은 '알고 쓰면 약인데, 모르고 쓰면 의도하지 않은 동작을 할 수도' 있다는 것이다.
OS(운영체제)에서 실행중인 하나의 프로그램을 프로세스라고 한다. 작업 관리자를 열어보면, 현재 운영체제에서 실행중인 프로세스들을 볼 수 있다.
이것들은 OS 로부터 메모리를 할당 받아 동작한다.
그리고 멀티 태스킹이라는 것도 있다.
딘어에서 느껴지는 것 그대로 동시에 여러가지 일을 처리하는 것을 말한다.
작업 관리자를 보면 동시에 여러가지 프로그램이 실행되고 있는 것을 볼 수 있는데, 이것도 멀티 태스킹의 하나라고 할 수 있다.
그렇다고 해서 멀티 태스킹이라는 것이 꼭 OS 레벨에서 동시에 여러 프로세스를 실행 하는 것만을 뜻하지는 않는다.
하나의 프로세스 안에서도 동시에 여러가지 일을 처리할 수 있다.
예를 들면 카카오톡 pc버전에서 메시지를 보내면서 파일을 전송 한다던가 하는 것들을 동시에 할 수 있다.
이렇게 하나의 프로세스 안에서 동시에 여러가지 작업을 할 수 있는 것은 각 작업마다 서로 다른 쓰레드를 생성하기 때문이다.
쓰레드는 작업 흐름이라고 할 수 있는데, 카카오톡의 예시에서 처럼 하나의 프로세스는 여러개의 쓰레드를 생성해서 동시에 처리할 수 있다.
이것을 멀티 쓰레드 라고 한다. 멀티 프로세스와는 서로 다른 것이다.
이 둘을 구분해야 하는 이유가 있다.
멀티 프로세스에서 각자의 프로세스는 OS로부터 서로 다른 메모리를 할당받아 독립적으로 동작하기 때문에, 하나의 프로세스가 문제가 생겼다고 해도 다른 프로세스에 영향을 주지 않는다.
즉, 카카오톡 메신저가 문제가 생겨 비정상 강제 종료를 하게 되었다고 해서 인터넷 브라우저나 IDE가 같이 종료되어 버리지 않는다.
하지만 같은 프로세스 안에서는 얘기가 다르다. 멀티 쓰레드 환경에서 하나의 쓰레드에 문제가 생기면, 그 쓰레드가 속한 프로세스 자체가 죽어버릴 수 있기 때문이다.
그리고 동시성(concurrency)과 병렬성(parallelism)이 있다.
멀티 쓰레드가 실행 될 때 이 두가지 중 하나로 실행된다.
이것은 cpu의 코어의 수와도 연관이 있는데, 하나의 코어에서 여러 쓰레드가 실행되는 것을 동시성,
멀티 코어를 사용할 때 각 코어별로 개별 쓰레드가 실행 되는 것을 병렬성 이라고 한다.
만약 코어의 수가 쓰레드의 수보다 많다면, 병렬성으로 쓰레드를 실행하면 되는데
코어의 수보다 쓰레드의 수가 더 많을 경우 동시성을 고려하지 않을 수 없다.
동시성을 고려 한다는 것은, 하나의 코어에서 여러 쓰레드를 실행 할 때 병렬로 실행하는 것처럼 보이지만
사실을 병렬로 처리하지 못하고 한 순간에는 하나의 쓰레드만 처리할 수 있어서 번갈아 가면서 처리하게 되는데
그 번갈아 가면서 수행하는게 워낙 빠르기 때문에 각자 병렬로 실행 되는 것처럼 보일 뿐이다.
Thread 클래스와 Runnable 인터페이스
Thread 클래스와 Runnable 인터페이스 모두 자바에서 쓰레드를 생성할 때 사용하는 것들이다.
다음 예제 코드의 주석을 참고하여 어떻게 쓰레드를 생성할 수 있는지 확인해보려 한다.
package me.xxxelppa.study.week10;
public class Exam_001 {
public static void main(String[] args) {
// Runnable 객체를 생성자 매개변수로 념겨줘서 생성하는 방법
Thread myThread_1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("MT_1 :: Runnable 객체를 Thread 클래스 생성자 매개변수로 사용");
}
});
// 위와 같은 방법이지만, 람다를 사용한 방법
Thread myThread_2 = new Thread(() -> {
System.out.println("MT_2 :: 람다를 사용하여 Runnable 객체를 Thread 클래스 생성자 매개변수로 사용");
});
// Thread 클래스를 상속 받은 클래스를 사용한 방법
Thread myThread_3 = new MyThreadClass();
// 익명 객체를 사용해서 쓰레드를 생성
Thread myThread_4 = new Thread() {
@Override
public void run() {
System.out.println("MT_4 :: 익명 객체를 사용");
}
};
// 생성한 쓰레드를 실행하기 위해서는 Thread 클래스의 start 메소드를 사용
myThread_1.start();
myThread_2.start();
myThread_3.start();
myThread_4.start();
}
}
class MyThreadClass extends Thread {
@Override
public void run() {
System.out.println("MT_3 :: Thread 클래스를 상속받아서 생성한 쓰레드 :: run 메소드를 override 한다.");
}
}
MT_2 :: 람다를 사용하여 Runnable 객체를 Thread 클래스 생성자 매개변수로 사용 MT_4 :: 익명 객체를 사용 MT_3 :: Thread 클래스를 상속받아서 생성한 쓰레드 :: run 메소드를 override 한다. MT_1 :: Runnable 객체를 Thread 클래스 생성자 매개변수로 사용
실행 결과를 보면 쓰레드를 실행한 순서대로 출력되지 않은 것을 볼 수 있다.
왜냐하면 각자 쓰레드를 시작은 했지만 실제로 자원을 할당 받아 특정한 한 순간에 실행되고 있는 쓰레드는 하나이기 때문에 언제 어떤 쓰레드가 자원을 할당 받아 실행될지는 알 수 없기 때문이다. (이 예제 코드를 기준으로)
그러면 별로 차이가 없어 보이는 Thread 클래스를 사용하는 것과 Runnable 인터페이스를 사용하는것에 어떤 차이가 있을까.
사실 이 질문에 이유가 있다. 클래스와 인터페이스의 차이이다.
쓰레드를 만들기 위해 클래스 상속을 받아버리면, 정말로 상속 받아야 할 클래스를 상속받지 못할 수 있기 때문에 보통 Runnable 인터페이스를 구현해서 쓰레드를 만든다.
또 다른점은 Thread 클래스가 Runnable 인터페이스 구현 객체를 전달 받아 실행할 수 있기 때문에 Runnable 구현 객체를 재사용할 수 있다.
package me.xxxelppa.study.week10;
public class Exam_002 {
public static void main(String[] args) {
new Thread(new MyRunnableClass()).start();
}
}
class MyRunnableClass implements Runnable {
@Override
public void run() {
System.out.println("Runnable 인터페이스를 구현하여 쓰레드 생성");
}
}
Runnable 인터페이스를 구현하여 쓰레드 생성
728x90
쓰레드의 상태
쓰레드에는 몇가지 상태가 있다.
이 상태는 열거 상수로 정의되어 확인해볼 수 있는데, 지금은 상태의 종류에 대해서만 알아보려 한다.
1. NEW
: 쓰레드가 생성 되었지만 아직 start 는 호출하지 않은 상태
2. RUNNABLE
: 실행 대기중인 상태로 언제든 실행 상태가 될 수 있는 상태
3. WAITING
: 일시 정지 중 하나로 다른 쓰레드의 notify 를 기다리는 상태
4. TIMED_WAITING
: 일시 정지 중 하나로 일정 시간 동안 기다리는 상태
5. BLOCKED
: 일시 정지 중 하나로 공유 객체의 락이 풀리기를 기다리는 상태
공유 객체의 락이라는 것은, 공유 객체에 대해 이미 다른 쓰레드가 사용중일 때 다른 쓰레드가 사용하지 못하도록 동기화 한 것을 말한다.
6. TERMINETED
: 모든 실행을 마친 상태
다음 예제를 통해 쓰레드의 상태를 출력해보는 것을 확인해보자.
package me.xxxelppa.study.week10;
public class Exam_007 {
public static void main(String[] args) {
ThreadStateChecker threadStateChecker = new ThreadStateChecker(new MyTargetThread());
threadStateChecker.start();
}
}
class ThreadStateChecker extends Thread {
Thread thread;
public ThreadStateChecker(Thread thread) {
this.thread = thread;
}
@Override
public void run() {
while(true) {
State myState = thread.getState();
System.out.println("쓰레드의 상태 : " + myState);
if(myState == State.NEW) thread.start();
if(myState == State.TERMINATED) break;
// 0.5초 통안 쓰레드 일시 정지
try { Thread.sleep(500); } catch (Exception e) { }
}
}
}
class MyTargetThread extends Thread {
@Override
public void run() {
for(long i = 0; i < 5000000000l; ++i) { }
// 1.5초 동안 쓰레드 일시 정지
try { Thread.sleep(1500); } catch (Exception e) { }
for(long i = 0; i < 5000000000l; ++i) { }
}
}
쓰레드의 상태 : NEW 쓰레드의 상태 : RUNNABLE 쓰레드의 상태 : RUNNABLE 쓰레드의 상태 : RUNNABLE 쓰레드의 상태 : TIMED_WAITING 쓰레드의 상태 : TIMED_WAITING 쓰레드의 상태 : TIMED_WAITING 쓰레드의 상태 : RUNNABLE 쓰레드의 상태 : RUNNABLE 쓰레드의 상태 : TERMINATED
쓰레드 두 개를 만들어서, 하나는 실행중인 쓰레드의 상태를 체크하는 쓰레드와
상태를 체크 당할 쓰레드를 만들어 상태가 어떻게 바뀌는지 확인해 보았다.
상태를 체크 당할 MyTargetThread가 실행 상태임을 확인하기 위해 for문을 공회전 하는 것을 넣었다.
상태를 체크 할 ThreadStateChecker 에서는
아직 쓰레드가 시작 전이라면 start 시키고, 종료 되었다면 break 로 while 문을 종료 하도록 했다.
쓰레드는 이렇게 몇가지 상태를 가질 수 있는데, 이 상태 값을 가지고 쓰레드의 동작을 제어할 수 있다.
wait 메소드는 쓰레드를 일시 정지 상태로 만든다. 그리고 notify 메소드는 wait에 의해 일시 정지 된 상태의 쓰레드를 실행 대기 상태로 만든다. 마지막으로 notifyAll 은 wait에 의해 일시 정지 된 모든 쓰레드들을 실행 대기 상태로 만든다.
주의할 것은.
이 세가지 메소드는 반드시 동기화 메소드 또는 동기화 블록 내에서만 사용 할 수 있다는 것이다.
wait와 notify를 사용해서 서로 번갈아가며 실행하는 쓰레드 예제를 만들어 보았다.
package me.xxxelppa.study.week10;
public class Exam_011 {
public static void main(String[] args) {
MySharedClass mySharedClass = new MySharedClass();
int workCount = 3;
MyWorker_1 myWorker_1 = new MyWorker_1(mySharedClass, workCount);
MyWorker_2 myWorker_2 = new MyWorker_2(mySharedClass, workCount);
myWorker_1.start();
myWorker_2.start();
}
}
class MySharedClass {
public synchronized void doWork_worker_1() {
System.out.println("worker 1 작업 중");
notify();
try { wait(); } catch (InterruptedException e) { }
}
public synchronized void doWork_worker_2() {
System.out.println("worker 2 작업 중");
notify();
try { wait(); } catch (InterruptedException e) { }
}
}
class MyWorker_1 extends Thread {
MySharedClass mySharedClass;
int workCount;
public MyWorker_1(MySharedClass mySharedClass, int workCount) {
this.workCount = workCount;
this.mySharedClass = mySharedClass;
}
@Override
public void run() {
for(int i = 0; i < workCount; ++i) {
mySharedClass.doWork_worker_1();
}
}
}
class MyWorker_2 extends Thread {
MySharedClass mySharedClass;
int workCount;
public MyWorker_2(MySharedClass mySharedClass, int workCount) {
this.workCount = workCount;
this.mySharedClass = mySharedClass;
}
@Override
public void run() {
for(int i = 0; i < workCount; ++i) {
mySharedClass.doWork_worker_2();
}
}
}
worker 1 작업 중 worker 2 작업 중 worker 1 작업 중 worker 2 작업 중 worker 1 작업 중 worker 2 작업 중
쓰레드 우선순위
앞서 쓰레드가 실행될 때 동시성과 병렬성 이라는 것이 있다고 했다.
쓰레드가 코어의 수보다 많아서 동시성으로 처리 되어야 할 경우 어떤 규칙(?)을 가지고 실행할지 알아야 하는데, 스케줄링 이라고 한다.
이 스케줄링은 두 가지 방식이 있는데, 하나가 우선순위 방식이고 또 다른 방식은 round robin 이라고 한다.
우선순위 방식이란 우선순위가 더 높은 쓰레드가 더 많은 실행 시간을 가지도록 하는 것이고, round robin 방식은 자원을 점유하는 시간은 정해져 있는 상태에서 서로 돌아가면서 그 시간동안 실행하는 방식을 말한다.
이 스레드의 우선순위는 사용자(개발자)가 정할 수 있다.
우선순위는 1 ~ 10까지 설정할 수 있고, 10이 가장 높은 우선순위를 나타낸다. 그리고 아무 우선순위도 할당하지 않으면 기본 값으로 5를 갖는다.
package me.xxxelppa.study.week10;
public class Exam_003 {
public static void main(String[] args) {
Thread myThread_1 = new MyRunnableThread("첫번째 쓰레드");
Thread myThread_2 = new MyRunnableThread("두번째 쓰레드");
myThread_2.setPriority(Thread.MAX_PRIORITY);
myThread_1.setPriority(3);
myThread_1.start();
myThread_2.start();
}
}
class MyRunnableThread extends Thread {
public MyRunnableThread(String name) {
setName(name);
}
@Override
public void run() {
int sum = 0;
for(int i = 0; i < 2147483647; ++i) {sum += i;}
System.out.println(getName());
}
}
두번째 쓰레드 첫번째 쓰레드
하지만 절대적으로 그 우선순위를 따르지는 않는다.
그래도 테스트 해보면 얼추 높은 우선순위를 할당한 쓰레드가 대부분 먼저 끝나는 것을 확인할 수 있다.
Main 쓰레드
일반적인 모든 Java 어플리케이션(프로그램)은 Main 이라는 쓰레드를 가지고 있다.
이 Main 쓰레드는 main이라는 메소드로부터 시작할 수 있다.
지금까지 지겹도록(?) 봐온 public static void main(String[] ar) 이게 바로 그 Main 쓰레드를 실행 시킨 메소드 이다.
처음 자바를 배울 당시에 '자바 프로그램은 main() 에서 시작해서 main() 에서 끝난다' 라는 말을 들은 적이 있다.
지금 생각해보면 100% 맞는 말은 아닌것 같지만 처음엔 그렇게 생각하는게 마음이 편할것 같다.
그렇기 때문에 지금 시점에서 일반적인 자바 프로그램을 만들어서 멀티 쓰레드를 구현하려면
Main 쓰레드를 실행 할 main() 메소드 안에서 새로운 쓰레드를 만들어 줘야 한다.
그리고 새롭게 생성한 쓰레드는 Main 쓰레드를 실행 할 main 메소드가 종료될 때 같이 종료 되도록 할 수도 있고,
main 쓰레드가 종료 되는 것과 상관 없이 별도로 생성한 쓰레드가 독자적으로 실행하다 종료되게 할 수도 있다.
package me.xxxelppa.study.week10;
public class Exam_004 {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
}
}
main
현재 실행중인 쓰레드의 이름을 출력 해보았다.
동기화
동기화 관련되어 문제가 발생할 수 있는 경우는, 멀티 쓰레드를 구현 하면서 공유 객체를 사용할 때이다.
여러 쓰레드에서 같은 객체를 공유해서 사용한다고 생각해보자.
돈을 넣고 빼는 것을 예로 생각해보자.
만약 동시에 여러 대의 ATM 기기를 점유하고 있다고 생각하자.
현재 계좌에 잔액이 1,000원이 있다.
여러대의 ATM 기계에서 잔액을 조외하면 전부 1,000원이 있다고 할 것이다.
만약 그 순간 다섯 대의 ATM 기계에서 동시에 1,000원을 출금 한다면 어떻게 될까?
5,000원이 나올까?
이런 경우를 방지하기 위해 동기화 (synchronized)라는 것을 사용한다. (또는 임계 영역(critical section)이라고도 한다.)
동기화를 사용하지 않은 예제 상황을 만들어 보면 다음과 같다.
package me.xxxelppa.study.week10;
public class Exam_005 {
public static void main(String[] args) {
MyAccount myAccount = new MyAccount();
Runnable runnable = new Runnable() {
@Override
public void run() {
myAccount.withdrawal(1000);
}
};
Thread myThread_1 = new Thread(runnable);
Thread myThread_2 = new Thread(runnable);
Thread myThread_3 = new Thread(runnable);
myThread_1.start();
myThread_2.start();
myThread_3.start();
}
}
class MyAccount {
private int myMoney = 1000;
public int withdrawal(int money) {
System.out.println(">>> 출금 시작");
// 찾으려는 금액보다 잔고가 많은 경우에만 출금
if(this.myMoney < money) {
System.out.println(" :: 잔액이 부족합니다.");
System.out.println(">>> 출금 종료");
return 0;
}
int returnMoney = money;
// 인출 하는데 1초가 걸린다고 가정
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(" :: 찾으신 금액 : " + money);
this.myMoney -= money;
System.out.println(">>> 출금 종료");
return returnMoney;
}
}
>>> 출금 시작 >>> 출금 시작 >>> 출금 시작 :: 찾으신 금액 : 1000 >>> 출금 종료 :: 찾으신 금액 : 1000 >>> 출금 종료 :: 찾으신 금액 : 1000 >>> 출금 종료
통장 잔액이 1,000원 인데 3,000원을 찾는 매직이 벌어질 수 있다. (이렇게 부자가 됩니다.)
찾는 입장에서는 행복할 수 있을지도 모르지만.. 아무튼 이런 일은 생기면 안된다.
이런 문제가 생긴 이유는, 공유 객체에 대한 동기화가 되지 않았기 때문이다.
아직 한 사람에 대한 출금 처리가 완료되지 않았는데, 또 다른 사람이 와서 출금을 요청했기 떄문에
또 다른 사람의 입장에서도 잔액이 남아있어서 출금이 가능했다는 문제이다.
그래서 동시에 접근했을 때 문제가 될 수 있는 부분에 대해서 동기화 처리를 해주어야 한다.
자바에서는 synchronized 키워드를 사용한다.
이 키워드를 사용하면 한 번에 하나의 쓰레드만 해당 자원에 접근해서 사용할 수 있도록 제한할 수 있다.
package me.xxxelppa.study.week10;
public class Exam_005 {
public static void main(String[] args) {
MyAccount myAccount = new MyAccount();
Runnable runnable = new Runnable() {
@Override
public void run() {
myAccount.withdrawal(1000);
}
};
Thread myThread_1 = new Thread(runnable);
Thread myThread_2 = new Thread(runnable);
Thread myThread_3 = new Thread(runnable);
myThread_1.start();
myThread_2.start();
myThread_3.start();
}
}
class MyAccount {
private int myMoney = 1000;
public synchronized int withdrawal(int money) {
System.out.println(">>> 출금 시작");
// 찾으려는 금액보다 잔고가 많은 경우에만 출금
if(this.myMoney < money) {
System.out.println(" :: 잔액이 부족합니다.");
System.out.println(">>> 출금 종료");
return 0;
}
int returnMoney = money;
// 인출 하는데 1초가 걸린다고 가정
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(" :: 찾으신 금액 : " + money);
this.myMoney -= money;
System.out.println(">>> 출금 종료");
return returnMoney;
}
}
>>> 출금 시작 :: 찾으신 금액 : 1000 >>> 출금 종료 >>> 출금 시작 :: 잔액이 부족합니다. >>> 출금 종료 >>> 출금 시작 :: 잔액이 부족합니다. >>> 출금 종료
앞선 예제와 비교해서 수정한 것은 27라인에 synchronized 하나뿐인데 의도한 대로 잘 동작하는것을 볼 수 있다.
마지막으로 synchronized 블록이 있다.
위에서 처럼 메소드에도 이 키워드를 붙여서 사용할 수 있지만, 이 키워드 자체로 블록을 만들어 사용할 수 있다.
이 블록을 사용하면, 메소드 전체에 대해 동기화(또는 락)을 걸어버리는 대신 메소드 내에서 특정 부분 특정 객체에 대해 락을 걸어 사용할 수 있다.
package me.xxxelppa.study.week10;
public class Exam_005 {
public static void main(String[] args) {
MyAccount myAccount = new MyAccount();
Runnable runnable = new Runnable() {
@Override
public void run() {
myAccount.withdrawal(1000);
}
};
Thread myThread_1 = new Thread(runnable);
Thread myThread_2 = new Thread(runnable);
Thread myThread_3 = new Thread(runnable);
myThread_1.start();
myThread_2.start();
myThread_3.start();
}
}
class MyAccount {
private int myMoney = 1000;
public int withdrawal(int money) {
System.out.println(">>> 출금 시작");
int returnMoney = money;
synchronized(this) { // 동기화 하려는 공유 객체를 넣어준다. this를 넣으면 자기 자신이 공유 객체임을 뜻한다.
// 찾으려는 금액보다 잔고가 많은 경우에만 출금
if(this.myMoney < money) {
System.out.println(" :: 잔액이 부족합니다.");
System.out.println(">>> 출금 종료");
return 0;
}
// 인출 하는데 1초가 걸린다고 가정
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(" :: 찾으신 금액 : " + money);
this.myMoney -= money;
}
System.out.println(">>> 출금 종료");
return returnMoney;
}
}
>>> 출금 시작 >>> 출금 시작 >>> 출금 시작 :: 찾으신 금액 : 1000 >>> 출금 종료 :: 잔액이 부족합니다. >>> 출금 종료 :: 잔액이 부족합니다. >>> 출금 종료
동기화 메소드를 사용했을 때와 비교해보면, 동기화 블록은 해당 영역에 대해서만 동기화를 하기 때문에
해당 임계 영역을 만나기 전까지 동시 호출은 가능한 것을 볼 수 있다.
데드락
데드락은 다른 말료 교착상태 라고도 한다.
교착 상태란 쉽게 말해서 오도가도 못하는 난처한 상태이다.
공유 객체에 대해 복수의 쓰레드가 서로 다른 쓰레드의 실행이 끝나기를 기다리고 있는 상태를 말한다.
데드락을 볼 수 있는 예제가 있어서 만들어 보았다.
package me.xxxelppa.study.week10;
public class Exam_006 {
public static Object myLockObj_1 = new Object();
public static Object myLockObj2 = new Object();
public static void main(String[] args) {
MyThread_1 myThread_1 = new MyThread_1();
MyThread_2 myThread_2 = new MyThread_2();
myThread_1.start();
myThread_2.start();
}
static class MyThread_1 extends Thread {
@Override
public void run() {
synchronized (myLockObj_1) {
System.out.println("Thread 1 : Holding [myLockObj_1]");
try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace();}
System.out.println("Thread 1 : Waiting for [myLockObj_2]");
synchronized (myLockObj2) {
System.out.println("Thread 1 : Holding [myLockObj_1] & [myLockObj_2]");
}
}
}
}
static class MyThread_2 extends Thread {
@Override
public void run() {
synchronized (myLockObj2) {
System.out.println("Thread 2 : Holding [myLockObj_2]");
try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println("Thread 2 : Waiting for [myLockObj_1]");
synchronized (myLockObj_1) {
System.out.println("Thread 2 : Holding [myLockObj_1] & [myLockObj_2]");
}
}
}
}
}
코드 내용은 단순하다.
Thread 1 은 시작 하자마자 myLockObj_1 객체에 락을 걸고 10ms 이후 myLockIObj_2 에 락을 건다.
Thread 2 는 시작 하자마자 myLockObj_2 객체에 락을 걸고 10ms 이후 myLockIObj_1 에 락을 건다.
자바에서 예외 처리 방법 (try, catch, throw, throws, finally)
자바가 제공하는 예외 계층 구조
Exception과 Error의 차이는?
RuntimeException과 RE가 아닌 것의 차이는?
커스텀한 예외 만드는 방법
자바에서 예외 처리 방법 (try, catch, throw, throws, finally), 커스텀한 예외 만드는 방법
자바에서 예외를 처리하는 방법은 다양하다.
try ~ catch 를 사용해서 예외를 직접 처리하는 방법.
try ~ catch ~ finally 를 사용해서 예외를 직접 처리하고, 예외가 발생해도 꼭 해야하는 후속처리까지 하는 방법.
throw를 사용해서 강제로 예외를 발생하는 방법.
에라 모르겠다. throws를 사용해서 나를 호출한 녀석에게 예외를 던지는 방법 등이 있다.
package me.xxxelppa.study.week09;
public class Exam_001 {
public static void main(String[] args) {
try {
// 예외가 발생할 수 있는 코드를 try 블록 안에 작성
} catch(Exception e) {
// 발생한 예외를 처리할 수 있는 코드를 catch 블록 안에 작성
// 예외가 발생하지 않으면 이 블록의 내용을 실행하지 않는다.
}
}
}
사실 처음 예외를 보면 finally 가 그다지 쓸모 없어 보이기도 한다.
finally 를 사용하는 다양한 경우가 있겠지만, 데이터베이스를 사용할 때 많이 사용한다.
하지만 데이터베이스에 대해 설명하려면 너무 복잡해지기 때문에 상황을 예로 들어보자.
냉장고 문을 열었으면 문을 다시 닫고, 음식을 먹었으면 설거지를 해놓는게 보통이다.
package me.xxxelppa.study.week09;
public class Exam_002 {
public static void main(String[] args) {
boolean isSnack = true;
boolean isMilk = true;
boolean isFood = true;
try {
// 냉장고 문을 연다.
// 간식을 찾는다.
// 간식이 없으면 예외 발생
if(!isSnack) throw new SnackNotFoundException();
// 우유가 없으면 예외 발생
if(!isMilk) throw new MilkNotFoundException();
// 먹을게 없으면 예외 발생
if(!isFood) throw new FoodNotFoundException();
// 간식을 꺼낸다.
// 냉장고 문을 닫는다.
} catch (SnackNotFoundException snfe) {
// 간식이 없으면 실행하는 블록
// 냉장고 문을 닫는다.
} catch (MilkNotFoundException mnfe) {
// 우유가 없으면 실행하는 블록
// 냉장고 문을 닫는다.
} catch (FoodNotFoundException mnfe) {
// 먹을게 없으면 실행하는 블록
// 냉장고 문을 닫는다.
}
}
}
class SnackNotFoundException extends Exception { }
class MilkNotFoundException extends Exception { }
class FoodNotFoundException extends Exception { }
try 블록 내에서 코드를 실행하는 도중 예외가 발생하면, try 블록 마지막에 실행하는 냉장고 문을 닫는 일을 할 수 없다.
먹고 싶은게 없다고 냉장고 문을 계속 열어두면, 등짝맞기 딱 좋다.
그래서 문을 닫아야 하는데, 모든 예외 상황에 문을 닫는다고 하려니 같은 내용이 반복 된다.
만약 냉장고 문을 그냥 닫지 않고, 이것좀 와서 보라고 한 다음에 냉장고 문을 닫고 싶으면 네곳을 고쳐야 한다.
슬픈일이 아닐 수 없다.
또 어쩌면 실수로 특정 예외 상황에서 냉장고 문을 닫는것을 깜빡하고 빠뜨릴 수도 있다.
등짝이 남아나지 않을거다.
728x90
예외가 발생해도 하지 않아도 무조건 냉장고 문을 닫아야 등짝이 터지지 않을 수 있기 때문에 finally 블록을 사용한다.
package me.xxxelppa.study.week09;
public class Exam_003 {
public static void main(String[] args) {
boolean isSnack = true;
boolean isMilk = true;
boolean isFood = true;
try {
// 냉장고 문을 연다.
// 간식을 찾는다.
// 간식이 없으면 예외 발생
if(!isSnack) throw new SnackNotFoundException();
// 우유가 없으면 예외 발생
if(!isMilk) throw new MilkNotFoundException();
// 먹을게 없으면 예외 발생
if(!isFood) throw new FoodNotFoundException();
// 간식을 꺼낸다.
} catch (SnackNotFoundException snfe) {
// 간식이 없으면 실행하는 블록
} catch (MilkNotFoundException mnfe) {
// 우유가 없으면 실행하는 블록
} catch (FoodNotFoundException mnfe) {
// 먹을게 없으면 실행하는 블록
} finally {
// 냉장고 문을 닫는다.
}
}
}
class SnackNotFoundException extends Exception { }
class MilkNotFoundException extends Exception { }
class FoodNotFoundException extends Exception { }
그리고 자연스럽게 넘어갔는데, 특정 예외를 강제로 발생하고 싶을 때 throw 를 사용할 수 있다.
이 throw 를 사용할 경우 크게 두 가지 방법으로 처리할 수 있다.
하나는 위에서 예시로 만든 것처럼 catch 블록을 사용해서 직접 후처리를 하는 것이고
다른 하나는 메소드에 throws 키워드를 사용해서 나를 호출한 녀석에게 예외를 처리 해달라고 던져버리는 것이다.
이 두번째 방법을 예외 전파라고도 한다.
package me.xxxelppa.study.week09;
public class Exam_004 {
public static void main(String[] args) {
try {
myMethod_1();
} catch (Exception e) {
// 예외가 전파되어온 스택을 출력한다.
e.printStackTrace();
}
}
public static void myMethod_1() throws Exception {
myMethod_2();
}
public static void myMethod_2() throws Exception {
myMethod_3();
}
public static void myMethod_3() throws Exception {
int a = 10;
int b = 0;
// 0으로 나누면 예외가 발생한다.
a = a / b;
}
}
java.lang.ArithmeticException: / by zero
at me.xxxelppa.study.week09.Exam_004.myMethod_3(Exam_004.java:25) at me.xxxelppa.study.week09.Exam_004.myMethod_2(Exam_004.java:17) at me.xxxelppa.study.week09.Exam_004.myMethod_1(Exam_004.java:13) at me.xxxelppa.study.week09.Exam_004.main(Exam_004.java:6)
실행 결과를 보면 최초로 예외가 무엇 때문에 어디서 발생 했는지 call stack 을 출력해서 보여주고 있다.
개인적인 생각이지만 일반적으로 사람들은 똑같은 일을 반복하는걸 별로 좋아하지 않는다.
특히 개발자들은 더 그런것 같다.
예외를 처리할 때 try ~ with ~ resource 라는게 있다.
자바 7부터 등장했는데, 다음과 같이 생겼다.
package me.xxxelppa.study.week09;
public class Exam_005 {
public static void main(String[] args) {
boolean isSnack = true;
boolean isMilk = true;
boolean isFood = true;
try (Refrigerator refrigerator = new Refrigerator()){
// 냉장고 문을 연다.
// 간식을 찾는다.
// 간식이 없으면 예외 발생
if(!isSnack) throw new SnackNotFoundException();
// 우유가 없으면 예외 발생
if(!isMilk) throw new MilkNotFoundException();
// 먹을게 없으면 예외 발생
if(!isFood) throw new FoodNotFoundException();
// 간식을 꺼낸다.
} catch (Exception e) {
if(e instanceof SnackNotFoundException) {
// 간식이 없으면 실행하는 블록
} else if(e instanceof MilkNotFoundException) {
// 우유가 없으면 실행하는 블록
} else if(e instanceof FoodNotFoundException) {
// 먹을게 없으면 실행하는 블록
}
e.printStackTrace();
}
}
}
class Refrigerator implements AutoCloseable {
@Override
public void close() throws Exception {
// 냉장고 문을 닫는다.
}
}
class SnackNotFoundException extends Exception { }
class MilkNotFoundException extends Exception { }
class FoodNotFoundException extends Exception { }
10라인에 try 블록에 소괄호 블록이 생겼다.
이 소괄호 블록 안에서 생성한 객체는 finally 에서 후처리를 해주지 않아도 후처리를 해준다.
대신 조건이 있다.
41라인에 해당 클래스가 정의되어 있는 것을 보면, AutoCloseable 라는 인터페이스를 구현하고 있는것을 볼 수 있다.
이 인터페이스를 정의하고 있는 클래스라면 위와같이 조금은 더 간결하게 코드를 작성할 수 있다.
마지막으로 커스텀한 예외는 위에서 예제를 코드를 만들어 보면서 작성 했다.
바로 다음 절에서 Checked 와 Unchecked 예외에 대해 설명 하지만,
Checked 예외 상황을 만들 경우 Exception을 포함한 자식 타입 클래스를, Unchecked 예외 상황을 만들 경우 RuntimeException을 포함한 자식 타입 클래스를 상속 받으면 된다.
package me.xxxelppa.study.week09;
public class Exam_006 {
public static void main(String[] args) throws MyException {
throw new MyException("내가 만든 커스텀한 예외");
}
}
class MyException extends Exception {
// super 생성자를 사용하면
// Exception 클래스의 부모인 Throwable 클래스의 생성자를 호출한다.
public MyException() {
super();
}
public MyException(String message) {
super(message);
}
}
Exception in thread "main" me.xxxelppa.study.week09.MyException: 내가 만든 커스텀한 예외
at me.xxxelppa.study.week09.Exam_006.main(Exam_006.java:6)
자바가 제공하는 예외 계층 구조, Exception과 Error의 차이는?, RuntimeException과 RE가 아닌 것의 차이는?
자바 api 문서를 참고해보면 모든 클래스의 최상위 부모인 Object 를 상속 받은 Throwable 을 상속 받은 Exception 과 Error 가 있다.
Error는 메모리가 부족하다던가 등의 JVM에 문제가 생겼을 경우에 발생하고,
Exception은 프로그램 코드 레벨에서 문제가 생겼을 경우에 발생하는 편이다.
Exception 은 또 크게 두가지로 구분해서 생각할 수 있는데, Checked 와 Unchecked 이다.
즉, 사용하는 인터페이스가 달라서 그 인터페이스를 사용하기 위해 새로운 인터페이스를 준비했다고 볼 수 있다.
이 인터페이스는 큰 특징이 있다.
java 1.8 이전 버전을 기준으로, 인터페이스는 두가지만 가질 수 있었다.
public static final 의 성격을 가지는 변수와 public abstract 의 성격을 가지는 구현 없이 정의만 있는 메소드이다.
만약 그냥 생략하고 작성 한다고 해도 소용없다.
생략하면 위에 얘기한 타입이 자동으로 붙고, 다른 타입을 사용하려하면 컴파일 오류가 발생한다.
package me.xxxelppa.study.week08;
public interface Exam_001 {
/*
* ERROR CASE
*
* public static final int test_01; // Variable 'test_01' might not have been initialized
* protected final int test_02 = 10; // Modifier 'protected' not allowed here
* private static final int test_03 = 10; // Modifier 'private' not allowed here
*
* private void mTest_01(); // Modifier 'private' not allowed here
* protected void mTest_02(); // Modifier 'protected' not allowed here
* void mTest_03() { } // Interface abstract methods cannot have body
*/
/*
* CAN USE CASE
*/
int test_01 = 10;
static int test_02 = 10; // Modifier 'static' is redundant for interface fields
final int test_03 = 10; // Modifier 'final' is redundant for interface fields
public static int test_04 = 10; // Modifier 'public' is redundant for interface fields, Modifier 'static' is redundant for interface fields
public final int test_05 = 10; // Modifier 'public' is redundant for interface fields, Modifier 'final' is redundant for interface fields
public static final int test_06 = 10; // Modifier 'public' is redundant for interface fields, Modifier 'static' is redundant for interface fields, Modifier 'final' is redundant for interface fields
void mTest_03();
abstract void mTest_02(); // Modifier 'abstract' is redundant for interface methods
public abstract void mTest_01(); // Modifier 'public' is redundant for interface methods, Modifier 'abstract' is redundant for interface methods
}
인터페이스를 정의하는 방법
인터페이스는 다음과 같이 정의할 수 있다.
package me.xxxelppa.study.week08;
public interface Exam_002 {
}
public 이외의 다른 접근 제한자를 사용하면 오류가 발생한다.
생략할 수는 있다.
728x90
인터페이스를 구현하는 방법, 인터페이스 레퍼런스를 통해 구현체를 사용하는 방법
인터페이스는 기본적으로 추상 메소드만 가지고 있기 때문에 어디선가 누군가는 구현을 해주어야 한다.
특정 interface를 구현하기 위해서는 클래스가 필요하고, 이 클래스는 구현 할 인터페이스를 상속 받아야 한다.
** 되도록이면 클래스나 인터페이스는 각자 독립된 파일에 작성하지만, 보기 편하기 위해 하나의 파일에 작성했다.
** 그리고 사실 인터페이스도 .java 확장자를 가지며, 컴파일하면 .class 파일이 생성 된다 !
앞서 인터페이스는 구현을 강제 한다고 했는데, 예를 들면 다음과 같이 할 수 있다.
하나의 예제에 너무 많은걸 넣은것 같지만, 천천히 보면 쉬운 내용이다.
package me.xxxelppa.study.week08;
interface MyInterface_01 {
void mustBeInvoked();
}
public class Exam_003 {
public static void main(String[] args) {
MyTestClass_01 mtc_01 = new MyTestClass_01();
MyTestClass_02 mtc_02 = new MyTestClass_02();
/*
* mtc_01 과 mtc_02 변수는 각각 특정 클래스의 객체를 담고 있다.
* 이 두 클래스는 모두 MyInterface_01 이라는 인터페이스를 상속받았으므로
* MyInterface_01 에 정의한 추상 메소드인 mustBeInvoked() 가 무조건 구현 되어있음을 알 수 있다.
*
* 그리고 정말 중요한 한가지가 더 있는데,
* 두 클래스가 같은 인터페이스를 상속 받았기 때문에 다음과 같이 배열로 관리할 수 있다.
*/
// 인터페이스 타입으로 묶어서 관리할 수 있다 !!!
MyInterface_01[] mi_01 = {
new MyTestClass_01()
, new MyTestClass_02()
};
// 같은 인터페이스를 구현하고 있기 때문에
// mustBeInvoked 메소드가 있음을 확신하고 사용할 수 있다.
for (MyInterface_01 mi : mi_01) {
mi.mustBeInvoked();
}
}
}
/*
* 인터페이스를 상속 받았는데,
* 추상 메소드를 구현하지 않으면 (즉, 오버라이딩 하지 않으면)
* 다음과 같은 컴파일 오류를 발생한다.
*
* Class 'MyTestClass_01' must either be declared abstract or implement abstract method 'mustBeInvoked()' in 'MyInterface_01'
*/
class MyTestClass_01 implements MyInterface_01 {
/*
* @Override 어노테이션을 생략해도 상관 없지만
* 오버라이딩 한 메소드임을 컴파일러에게 알려주고
* 사용자 입장에서도 한 눈에 오버라이딩 한 메소드임을 알려주기 위해
* 굳이 사용하는 것을 권장한다.
*/
@Override
public void mustBeInvoked() {
System.out.println("DO SOMETHING!");
}
}
class MyTestClass_02 implements MyInterface_01 {
@Override
public void mustBeInvoked() {
System.out.println("DOBBY IS FR22");
}
}
DO SOMETHING! DOBBY IS FR22
위 예제를 온전히 이해한다면, 인터페이스에서 가장 빈번하게 사용하는 개념을 알고 있다고 해도 과언이 아니라고 생각한다.
왜냐면 어차피 구현되어있지 않기 때문에, 똑같은 메소드를 상속 받아도 한 번만 구현하면 문제 없기 때문이다.
package me.xxxelppa.study.week08;
public class Exam_005 implements MyInterface_05, MyInterface_06 {
@Override
public void test() {
System.out.println("한 번만 오버라이딩 하면 된다.");
}
}
interface MyInterface_05 { void test(); }
interface MyInterface_06 { void test(); }
인터페이스의 기본 메소드 (Default Method), 자바 8
인터페이스는 자바 1.8 부터 기능이 확장 되었다.
이전에는 '인터페이스 구현 메소드 가지고 있는 소리 하네' 하는 농담이 가능했지만 앞으로는 그러면 안된다.
바로 이 default 메소드가 구현부를 가지는 메소드이기 때문이다.
그럼 이게 왜 생겼을까?
적어도 자바를 쓰는 사람 혼란스러우라고 만든건 아니다.
인터페이스를 잘 생각 해보면, 인터페이스를 상속 받은 클래스는 인터페이스 내부에 정의한 추상 메소드 구현을 해야만 한다고 했다.
여기까지는 좋은데, 만약 특정 인터페이스를 구현하고 있는 클래스가 100개가 있는데 이 인터페이스에 추상 메소드 하나를 더 추가 해야 한다고 해보자.
새롭게 추가된 추상 메소드를 사용하는 클래스도 있겠지만, 사용하지 않아도 되는 클래스도 분명 있을 것이다.
그러면 사용하지 않는 클래스에서는 울며 겨자먹기로 아무것도 하지 않지만 비어있는 실행 블록을 작성 해야만 하는 상황에 놓일 수 있다.
끔찍한 일이 아닐 수 없다.
이런 상황에 클래스에서 인터페이스 내부의 메소드를 선택적으로 오버라이딩 해서 사용할 수 있도록
즉, 굳이 오버라이딩 하지 않아도 컴파일 오류가 발생하지 않는 메소드를 인터페이스에 추가하기 위해서 만든 것이 default 메소드 이다.
사용 방법은 간단하다.
package me.xxxelppa.study.week08;
public class Exam_006 implements MyInterface_07 {
// 인터페이스 내부 메소드를 오버라이딩 하지 않았지만 오류가 발생하지 않는다.
public void doSomething() {
// 이렇게 호출해서 사용할 수도 있다.
myTestMethod();
}
}
interface MyInterface_07 {
default void myTestMethod() {
System.out.println("인터페이스도 구현 메소드를 가질 수 있다.");
}
}
인터페이스의 static 메소드, 자바 8
default 메소드와 마찬가지로 static 메소드도 자바 1.8부터 생겼다.
자바에서 static 이랑 키워드를 만나면, 일단 동적이지 않고 정적이라는 생각을 하면서 객체를 만들지 않고 사용할 수 있다고 생각 하면 좋다.
일단 static 은 메모리에 로드 되는 시점이 다르다.
new 연산자를 사용하면 런타임에 동적으로 객체가 생성되어 메모리에 로드 되는 것이 아니고, 클래스가 로드 되는 시점에 생성 된다.
그렇기 때문에 static 으로 선언된 것들은 객체를 생성하지 않고 바로 사용할 수 있다.
클래스의 멤버 필드나 멤버 메소드의 경우 static 키워드를 사용하면, 해당 클래스 이름으로 객체 생성 없이 바로 사용할 수 있다.
package me.xxxelppa.study.week08;
public class Exam_007 {
public static void main(String[] args) {
System.out.println(" :: " + MyStaticTestClass.static_value);
MyStaticTestClass.static_method();
// Non-static field 'non_static_value' cannot be referenced from a static context
// System.out.println(" :: " + MyStaticTestClass.non_static_value);
// Non-static method 'non_static_method()' cannot be referenced from a static context
// MyStaticTestClass.non_static_method();
}
}
class MyStaticTestClass {
public int non_static_value = 10;
public static int static_value = 20;
public void non_static_method() { }
public static void static_method() { }
}
조금 다른 길로 새어나갔는데, 인터페이스의 static 메소드도 클래스의 멤버에 static 키워드를 붙인 것과 마찬가지로
인터페이스 이름으로 바로 접근해서 사용할 수 있다.
package me.xxxelppa.study.week08;
public class Exam_008 {
public static void main(String[] args) {
MyInterface_08.my_static_method();
}
}
interface MyInterface_08 {
static void my_static_method() {
System.out.println("static method 입니다.");
}
}
인터페이스의 static 메소드 관련해서 재밌는것이 하나 더 있다.
바로 오버라이딩 할 수 없다는 것이다.
package me.xxxelppa.study.week08;
public class Exam_009 implements MyInterface_09 {
// @Override // Method does not override method from its superclass
static void my_static_method() {
System.out.println("오버라이딩 하고 싶은데..");
}
public static void main(String[] args) {
my_static_method();
MyInterface_09.my_static_method();
}
}
interface MyInterface_09 {
static void my_static_method() {
System.out.println("오버라이딩 할 수 없습니다.");
}
}
오버라이딩 하고 싶은데.. 오버라이딩 할 수 없습니다.
오버라이딩이 불가능한 이유에 대해 찾아보았는데, 다음과 같은 내용을 찾아낼 수 있었다.
Overloading is the mechanism of binding the method call with the method body dynamically based on the parameters passed to the method call. Static methods are bonded at compile time using static binding. Therefore, we cannot override static methods in Java.
Unlike other methods in Interface, these static methods contain the complete definition of the function and since the definition is complete and the method is static, therefore these methods cannot be overridden or changed in the implementation class.
인터페이스의 private 메소드, 자바 9
자바 9에서는 인터페이스에 private 메소드를 사용할 수 있게 되었다.
private 키워드를 사용해서 일반 메소드와 static 메소드 두 가지를 정의할 수 있는데
평범한 클래스에서 (plain old java class .. ?????) 사용하는 것과 같다고 생각하면 된다.
예를 들면 static 메소드에서 non static 메소드를 사용할 수 없다던가 하는 것들이다.
package me.xxxelppa.study.week08;
public class Exam_010 {
}
interface MyInterface_10 {
default void my_default_method() {
// private 메소드 사용 가능
my_private_method();
System.out.println("my default method");
}
private void my_private_method() {
// static 메소드 사용 가능
my_private_static_method();
System.out.println("my private method");
}
static void my_static_method() {
// 다른 static 메소드 사용 가능
my_another_private_static_method();
System.out.println("my static method");
}
static void my_another_static_method() {
// 다른 일반 메소드 사용 불가능
// non-static method my_private_method() cannot be referenced from a static context
// my_private_method();
}
private static void my_private_static_method() {
// 다른 static 메소드 사용 가능
my_static_method();
System.out.println("my private static method");
}
private static void my_another_private_static_method() {
System.out.println("my another private static method");
}
}