애너테이션(Annotation)
애너테이션이란?
소스 코드가 컴파일되거나 실행될 때 컴파일러 및 다른 프로그램에게 필요한 정보를 전달해 주는 문법 요소이다.
컴파일러 또는 다른 프로그램에 필요한 정보를 제공해 주는 역할을 한다.
public class ExampleClass implements ExampleInterface {
@Override // 이 @Override 가 애너테이션이다.
public void example() {
}
}
위의 코드 예제에서 보이는 @Override가 바로 애너테이션입니다. 보이는 것처럼 애너테이션은 @로 시작하며, 클래스, 인터페이스, 필드, 메서드 등에 붙여서 사용할 수 있다.
애너테이션의 종류
JDK에서 기본적으로 제공하는 애너테이션은 아래의 두 가지로 구분된다.
- 표준 애너테이션 : JDK에 내장된 일반적인 애너테이션
- 메타 애너테이션 : 다른 애너테이션을 정의할 때 사용하는 애너테이션
표준 애너테이션
자바에서 기본적으로 제공하는 표준 애너테이션은 여러가지가 있지만, 그 중 가장 빈번하게 사용되는 네 가지의 표준 애너테이션을 간략하게 살펴보자
@Override
@Override는 메서드 앞에만 붙일 수 있는 애너테이션으로, 선언한 메서드가 상위 클래스의 메서드를 오버라이딩하거나 추상 메서드를 구현하는 메서드라는 것을 컴파일러에게 알려주는 역할을 수행한다.
아래 코드 예제와 같이 SuperClass의 example()를 SubClass에서 오버라이딩할 때에, @Override를 붙여주면, 컴파일러는 SubClass의 example()이 상위 클래스의 메서드를 오버라이딩한 것으로 간주한다.
class SuperClass {
public void example() {
System.out.println("example() of SuperClass");
}
}
class SubClass extends SuperClass {
@Override
public void example() {
System.out.println("example() of SubClass");
}
}
컴파일 과정에서 컴파일러가 @Override 를 발견하면, @Override 가 붙은 메서드와 같은 이름을 가진 메서드가 상위 클래스 (또는 인터페이스)에 존재하는지 검사한다. 즉, SuperClass에 example이 있는지 검사한다.
상위클래스나 인터페이스에 @Override가 붙어있는 메서드명과 같은 이름의 메서드를 찾을 수 없다면 컴파일 에러가 발생한다.
상위 클래스에 오버라이딩 같은 똑같은 이름의 메서다가 존재하는지 왜 확인해야 할까?
종종 코드를 작성하다 보면 메서드를 오버라이딩하거나 구현할 때, 개발자의 실수로 메서드의 이름이 잘못 작성되는 경우가 발생한다.
class SuperClass {
public void example() {
System.out.println("example() of SuperClass");
}
}
class SubClass extends SuperClass {
public void exapmle() { // 메서드 이름에 오타가 있습니다.
System.out.println("example() of SubClass");
}
}
위의 코드 예시 처럼 @Overrid를 붙이지 않으면 컴파일러는 example()이라는 새로운 메서드를 정의하는 것으로 간주하고, 에러를 발생시키지 않는다.
이런 경우, 컴파일 에러 없이 코드가 그대로 실행 될 수 있어 실행 시에 런타임 에러가 발생할 것이며, 런타임 에러 발생 시에, 어디에서 에러가 발생했는지 에러의 원인을 찾아내기 어려워진다.
그러나, @Override를 사용하면 example() 이 오버라이딩 메서드라는 것을 컴파일러가 인지하고, 상위 클래스에서 example()이 존재하는지 확인하기 때문에, 위의 상황을 방지할 수 있다.
다시 말하면, @Override는 컴파일러에게 “컴파일러야, 이 메서드는 상위 클래스의 메서드를 오버라이딩하거나 추상 메서드를 구현하는 메서드인데, 만약에 내가 실수해서 오버라이딩 및 구현이 잘 안 되면 에러를 발생시켜서 나에게 알려줄래?”라고 부탁하는 것과 같다.
@Deprecated
@Deprecated는 기존에 사용하던 기술이 다른 기술로 대체되어 기존 기술을 적용한 코드를 더 이상 사용하지 않도록 유도하는 경우에 사용한다.
아래 코드 예시를 보자.
class OldClass {
@Deprecated
private int oldField;
@Deprecated
int getOldField() { return oldField; }
}
OldClass의 oldField와 getOldField()에 @Deprecated가 붙어 있다.
이때, 다른 클래스에서 OldClass를 인스턴스화하여 getOldFiled()를 호출하면 인텔리제이가 경고메세지를 출력한다.
public class Main {
public static void main(String[] args) {
OldClass oldClass = new OldClass();
System.out.println(oldClass.getOldField());
}
}
class OldClass {
@Deprecated
private int oldField;
@Deprecated
int getOldField() { return oldField; }
}
@Deprecated는 애너테이션이 붙은 대상이 새로운 것으로 대체되었으니 기존의 것을 사용하지 않도록 유도하는 기능을 한다. 기존의 코드를 다른 코드와의 호환성 문제로 삭제하기 곤란해 남겨두어야만 하지만 더 이상 사용하는 것을 권장하지 않을 때 @Deprecated를 사용한다.
@SuppressWarnings
@SuppressWarnings 애너테이션은 컴파일 경고 메시지가 나타나지 않도록 한다. 때에 따라서 경고가 발생할 것이 충분히 예상됨에도 묵인해야 할 때 주로 사용한다.
@SuppressWarnings 뒤에 괄호를 붙이고 그 안에 억제하고자 하는 경고메세지를 지정할 수 있다.
중괄호에 여러 개의 경고 유형을 나열하면 한 번에 여러개의 경고를 묵인하게 할 수 있다.
@SuppressWarnings({"deprecation", "unused", "null"})
@FunctionalInterface
@FunctionalInterface 애너테이션은 함수형 인터페이스를 선언할 때, 컴파일러가 함수형 인터페이스의 선언이 바르게 선언되었는지 확인하도록 한다. 만약 바르게 선언되지 않은 경우, 에러를 발생시킨다.
함수형 인터페이스는 단 하나의 추상 메서드만을 가져야 하는 제약이 있다.
@FunctionalInterface
public interface ExampleInterface {
public abstract void example(); // 단 하나의 추상 메서드
}
메타 애터네이션
메타 애너테이션(meta-annotation)은 애너테이션을 정의하는 데에 사용되는 애너테이션으로, 애너테이션의 적용 대상 및 유지 기간을 지정하는 데에 사용된다.
먼저, 표준 애터네이션 @Override가 어떻게 정의되어 있는지 확인해 보자. @Override의 소스 코드는 아래와 같다.
@Retention(RetentionPolicy.SOURCE)
public @interface Override{
}
위 코드를 보면 알 수 있는 것처럼, 애터네이션을 정의할 때 @interface 키워드를 사용하여 정의한다.
또한, 애너테이션 정의부 상단에 @Target, @Retention 애너테이션이 붙어 있는 것을 확인할 수 있다.
이들은 @Override의 적용 대상과 유지 기간을 지정하는 역할을 한다.
@Target
@Target 애너테이션은 이름 그대로 애너테이션을 적용할 “대상"을 지정하는 데 사용된다.
아래 표에 정리되어 있는 내용이 @Target 애터네이션을 사용하여 지정할 수 있는 대상의 타입이다.
모두 java.lang.annotation.ElementType이라는 열거형에 정의되어 있다.
대상 타입 | 적용 범위 |
ANNOTATION_TYPE | 애너테이션 |
CONSTRUCTOR | 생성자 |
FIELD | 필드(멤버변수, 열거형 상수) |
LOCAL_VARIABLE | 지역변수 |
METHOD | 메서드 |
PACKAGE | 패키지 |
PARAMETER | 매개변수 |
TYPE | 타입(클래스, 인터페이스, 열거형) |
TYPE_PARAMETER | 타입 매개변수 |
TYPE_USE | 타입이 사용되는 모든 대상 |
@Target 코드의 용법을 다음의 코드 예시를 통해 살펴보자.
import static java.lang.annotation.ElementType.*;
//import문을 이용하여 ElementType.TYPE 대신 TYPE과 같이 간단히 작성할 수 있습니다.
@Target({FIELD, TYPE, TYPE_USE}) // 적용대상이 FIELD, TYPE
public @interface CustomAnnotation { } // CustomAnnotation을 정의
@CustomAnnotation // 적용대상이 TYPE인 경우
class Main {
@CustomAnnotation // 적용대상이 FIELD인 경우
int i;
}
애너테이션을 사용자가 직접 정의할 때(@interface 사용), @Target 애너테이션을 통해 사용자가 정의한 애너테이션이 어디에 적용될 수 있는지를 설정할 수 있다.
코드 예시에서 @Target({FIELD, TYPE, TYPE_USE}) 을 사용하여, 각각 필드, 타입, 그리고 타입이 사용되는 모든 대상(변수)에 애너테이션이 적용되도록 한 것을 확인할 수 있다.
@Documented
@Documented 애너테이션은 애너테이션에 대한 정보가 javadoc으로 작성한 문서에 포함되도록 하는 애너테이션 설정이다.
자바에서 제공하는 표준 애너테이션과 메타 애너테이션 중 @Override와 @SuppressWarnings를 제외하고는 모두 @Documented가 적용되어 있다.
@Documented
@Target(ElementType.Type)
public @interface CustomAnnotation { }
@Inherited
@Inherited 애너테이션은 하위 클래스가 애너테이션을 상속받도록 한다. @Inherited 애너테이션을 상위 클래스에 붙이면, 하위 클래스도 상위 클래스에 붙은 애너테이션들이 동일하게 적용된다.
@Inherited // @SuperAnnotation이 하위 클래스까지 적용
@interface SuperAnnotation{ }
@SuperAnnotation
class Super { }
class Sub extends Super{ } // Sub에 애너테이션이 붙은 것으로 인식
코드 예시에서 Super 상위 클래스로부터 확장된 Sub 하위 클래스는 상위 클래스와 동일하게 @SuperAnnotation에 정의된 내용들을 적용받게 된다.
@Retention
@Retention 애너테이션은 애너테이션의 지속 시간을 결정하는 데 사용한다. 애너테이션과 관련한 유지 정책(retention policy)의 종류에는 다음의 세 가지가 있습니다.
유지정책이란 애너테이션이 유지되는 기간을 지정하는 속성을 말한다.
유지 정책 | 설명 |
SOURCE | 소스 파일에 존재, 클래스파일에는 존재하지 않음 |
CLASS | 클래스 파일에 존재, 실행 시에 사용 불가, 기본값 |
RUNTIME | 클래스 파일에 존재, 실행 시에 사용가능 |
각각의 유지 정책은 언제까지 애너테이션이 유지될지를 결정한다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
//오버라이딩이 제대로 되었는지 컴파일러가 확인하는 용도
//클래스 파일에 남길 필요 없이 컴파일 시에만 확인하고 사라짐
public @interface Override(){ }
위의 코드 예시에서 Override 애너테이션은 컴파일러가 사용하면 끝나기 때문에, 실행 시에는 더 이상 사용되지 않음을 의미한다.
@Repeatable
@Repeatable 애너테이션은 애너테이션을 여러 번 붙일 수 있도록 허용한다는 의미가 있다.
아래 코드 예시를 보면, 사용자 타입의 애너테이션 Work를 정의하고, @Repeatable 애너테이션을 사용하여 이것을 여러 번 사용할 수 있도록 했다.
@Repeatable(Works.class) // Work 애너테이션을 여러 번 반복해서 쓸 수 있게 한다.
@interface Work{
String value();
}
이제 아래와 같이 일반적인 애너테이션과 다르게 Work 애너테이션을 하나의 대상에 여러 번 적용하는 것이 가능해졌다.
@Work("코드 업데이트")
@Work("메서드 오버라이딩")
class Main{
... 생략 ...
}
참고로, @Repeatable 애너테이션은 일반적인 애너테이션과 달리 같은 이름의 애너테이션이 여러 번 적용될 수 있기 때문에, 이 애너테이션들을 하나로 묶어주는 애너테이션도 별도로 작성해야 한다.
@interface Works { // 여러 개의 Work애너테이션을 담을 컨테이너 애너테이션 Works
Work[] value();
}
@Repeatable(Works.class) // 컨테이너 애너테이션 지정
@interface Work {
String value();
}
사용자 정의 애너테이션
사용자 정의 애너테이션은 이름 그대로 사용자가 직접 애너테이션을 정의해서 사용하는 것을 의미한다.
애너테이션을 정의하는 방법은 인터페이스를 정의하는 것과 비슷하다.
@interface 애너테이션명 { // 인터페이스 앞에 @기호만 붙이면 애너테이션을 정의할 수 있습니다.
타입 요소명(); // 애너테이션 요소를 선언
}
한 가지 유의할 점은, 애너테이션은 java.lang.annotation 인터페이스를 상속받기 때문에 다른 클래스나 인터페이스를 상속받을 수 없다는 사실이다.
람다(Lambda)
람다식
람다식(Lambda Expression)은 함수형 프로그래밍 기법을 지원하는 자바의 문법요소이다.
람다식은 메서드를 하나의 ‘식(expression)’으로 표현한 것으로, 코드를 매우 간결하면서 명확하게 표현할 수 있다는 큰 장점이 있다.
람다식의 기본 문법
아래 코드 예시는 같은 메서드를 각각 기존의 방식과 람다식으로 표현한 것이다.
//기존 메서드 표현 방식
void sayhello() {
System.out.println("HELLO!")
}
//위의 코드를 람다식으로 표현한 식
() -> System.out.println("HELLO!")
두 코드의 가장 두드러지는 차이는 람다식에서는 기본적으로 반환타입과 이름을 생략할 수 있다는 점이다. 따라서 람다함수를 종종 이름이 없는 함수, 즉 익명 함수(anonymous function)라 부르기도 한다.
아래 코드 예시를 통해 메서드를 람다식으로 만드는 방법을 확인해보자.
먼저 더하기 기능을 수행하는 sum 이라는 메서드가 있다.
int sum(int num1, int num2) {
return num1 + num2;
}
이 메서드를 람다식으로 바꿔보자
반환타입과 메서드명을 제거하고 코드 블록 사이에 화살표를 추가해 준다.
(int num1, int num2) -> { // 반환타입과 메서드명 제거 + 화살표 추가
return num1 + num2;
}
아래는 람다식을 적용한 다른 식의 코드들이다.
// 기존 방식
void example1() {
System.out.println(5);
}
// 람다식
() -> {System.out.println(5);}
// 기존 방식
int example2() {
return 10;
}
// 람다식
() -> {return 10;}
// 기존 방식
void example3(String str) {
System.out.println(str);
}
// 람다식
(String str) -> { System.out.println(str);}
이렇게 람다식을 사용하면 기존 방식을 좀 더 간편하고 명확하게 표현할 수 있다.
여기서 특정 조건이 충족된다면 람다식을 더욱 축약하여 표현할 수 있다.
다시 sum 메서드를 기존 방식과 람다식으로 작성해보았다.
// 기존 방식
int sum(int num1, int num2) {
return num1 + num2;
}
// 람다식
(int num1, int num2) -> {
num1 + num2
}
이 sum 메서드를 조금 더 축약해보자.
먼저, 메서드 바디에 문장이 실행문이 하나만 존재할 때 중괄호와 return 문을 생략할 수 있다. 이 경우, 세미콜론까지 생략해야 한다. 실행문이 두 개 이상이면 중괄호를 생략할 수 없다.
(int num1, int num2) -> num1 + num2
두 번째로, 매개변수 타입을 함수형 인터페이스를 통해 유추할 수 있는 경우에는 매개변수의 타입을 생략할 수 있다.
(num1, num2) -> num1 + num2
함수형 인터페이스
자바에서 함수는 반드시 클래스 안에서 정의되어야 하므로 메서드가 독립적으로 있을 수 없고 반드시 클래스 객체를 먼저 생성한 후 생성한 객체로 메서드를 호출해야 한다.
이런 맥락에서 지금까지 우리가 메서드와 동일시 여겼던 람다식 또한 사실은 객체이다.
더 정확히는 이름이 없기 때문에 익명 객체라 할 수 있다.
앞서 봤던 sum 메서드를 다시 한번 확인해보자.
// sum 메서드 람다식
(num1, num2) -> num1 + num2
// 람다식을 객체로 표현
new Object() {
int sum(int num1, int num2) {
return num1 + num1;
}
}
위의 람다식으로 표현한 sum 메서드는 사실 아래와 같은 이름이 없는 익명 객체다.
익명 객체는 익명 클래스를 통해 만들 수 있는데, 익명 클래스란 객체의 선언과 생성을 동시에 하여 오직 하나의 객체를 생성하고, 단 한 번만 사용되는 일회용 클래스이다.
만약에 람다식이 객체라 한다면 앞서 우리가 배웠던 것처럼 이 객체에 접근하고 사용하기 위한 참조변수가 필요하다.
그런데 기존에 객체를 생성할 때 만들었던 Object 클래스에는 sum이라는 메서드가 없으므로, Object 타입의 참조변수에 담는다고 하더라도 sum 메서드를 사용할 수 없다.
public class LamdaExample1 {
public static void main(String[] args) {
// 람다식 Object obj = (num1, num2) -> num1 + num2; 로 대체 가능
Object obj = new Object() {
int sum(int num1, int num2) {
return num1 + num1;
}
};
obj.sum(1, 2);
}
}
//출력 결과
java: cannot find symbol
symbol: method sum(int,int)
location: variable obj of type java.lang.Object
위의 코드 예시에서 익명 객체를 생성하여 참조변수 obj에 담아준다 하더라도 sum 메서드를 사용할 수 있느 방법이 없다.
이런 문제를 해결하기 위해 사용하는 자바의 문법 요소가 자바의 함수형 인터페이스(Functional Interface)라 할 수 있다.
즉, 자바에서 함수형 프로그래밍을 하기 위한 새로운 문법 요소를 도입하는 대신, 기존의 인터페이스 문법을 활용하여 람다식을 다루는 것이라 할 수 있다.
함수형 인터페이스에는 단 하나의 추상 메서드만 선언될 수 있는데, 이는 람다식과 인터페이스의 메서드가 1:1로 매칭되어야 하기 때문이다.
위의 코드 예시에서 나왔던 오류를 함수형 인터페이스를 적용하여 해결하자
public class LamdaExample1 {
public static void main(String[] args) {
/* Object obj = new Object() {
int sum(int num1, int num2) {
return num1 + num1;
}
};
*/
ExampleFunction exampleFunction = (num1, num2) -> num1 + num2;
System.out.println(exampleFunction.sum(10,15));
}
@FunctionalInterface // 컴파일러가 인터페이스가 바르게 정의되었는지 확인하도록 합니다.
interface ExampleFunction {
int sum(int num1, int num2);
}
// 출력값
25
위의 코드 예시에서 함수형 인터페이스인 ExampleFunction에 추상메서드 sum()이 정의되어 있다. 이 함수형 인터페이스는 람다식을 참조할 참조변수를 선언할 때, 타입으로 사용하기 위해 필요하다.
함수형 인터페이스 타입으로 선언된 참조변수 exampleFunction에 람다식이 할당되었으며, 이제 exampleFunction을 통해 sum() 메서드를 호출할 수 있다.
이처럼, 함수형 인터페이스를 사용하면 참조변수의 타입으로 함수형 인터페이스를 사용하여 우리가 원하는 메서드에 접근할 수 있다.
매개변수와 리턴값이 없는 람다식
다음과 같이 매개변수와 리턴값이 없는 추상 메서드를 가진 함수형 인터페이스가 있다고 가정하자
@FunctionalInterface
public interface MyFunctionalInterface {
void accept();
}
이 인터페이스를 타입으로 갖는 람다식은 다음과 같은 형태로 작성해야 한다. 람다식에서 매개변수가 없는 이유는 accept()가 매개변수를 가지지 않기 때문이다.
MyFunctionalInterface example = () -> { ... };
// example.accept();
람다식이 대입된 인터페이스의 참조 변수는 위의 주석과 같이 accept()를 호출할 수 있다. accept()의 호출은 람다식의 중괄호 {}를 실행시킨다.
@FunctionalInterface
interface MyFunctionalInterface {
void accept();
}
public class MyFunctionalInterfaceExample {
public static void main(String[] args) throws Exception {
MyFunctionalInterface example = () -> System.out.println("accept() 호출");
example.accept();
}
}
// 출력값
accept() 호출
매개변수가 있는 람다식
매개 변수가 있고 리턴값이 없는 추상 메서드를 가진 함수형 인터페이스가 있다고 가정하자.
@FunctionalInterface
public interface MyFunctionalInterface {
void accept(int x);
}
이 인터페이스를 타깃 타입으로 갖는 람다식은 다음과 같은 형태로 작성해야 한다. 람다식에서 매개변수가 한 개인 이유는 추상메서드 accept()가 매개변수를 하나만 가지기 때문이다.
public class MyFunctionalInterfaceExample {
public static void main(String[] args) throws Exception {
MyFunctionalInterface example;
example = (x) -> {
int result = x * 5;
System.out.println(result);
};
example.accept(2);
example = (x) -> System.out.println(x * 5);
example.accept(2);
}
}
// 출력값
10
10
람다식이 대입된 인터페이스 참조 변수는 다음과 같이 accept()를 호출할 수 있다. 위의 예시와 같이 매개값으로 2를 주면 람다식의 x 변수에 2가 대입되고, x는 중괄호 { }에서 사용된다.
리턴값이 있는 람다식
매개 변수와 리턴값을 가지는 추상 메서드를 포함하는 함수형 인터페이스가 있다고 가정하자.
@FunctionalInterface
public interface MyFunctionalInterface {
int accept(int x, int y);
}
이 인터페이스를 타깃 타입으로 갖는 람다식은 다음과 같은 형태로 작성해야 한다. 람다식에서 매개 변수가 두 개인 이유는 accept()가 매개변수를 두 개 가지기 때문이다.
또한, accept()가 리턴 타입이 있기 때문에 중괄호 { }에는 return 문이 있어야 한다.
public class MyFunctionalInterfaceExample {
public static void main(String[] args) throws Exception {
MyFunctionalInterface example;
example = (x, y) -> {
int result = x + y;
return result;
};
int result1 = example.accept(2, 5);
System.out.println(result1);
example = (x, y) -> { return x + y; };
int result2 = example.accept(2, 5);
System.out.println(result2);
example = (x, y) -> x + y;
//return문만 있으면, 중괄호 {}와 return문 생략 가능
int result3 = example.accept(2, 5);
System.out.println(result3);
example = (x, y) -> sum(x, y);
//return문만 있으면, 중괄호 {}와 return문 생략 가능
int result4 = example.accept(2, 5);
System.out.println(result4);
}
public static int sum(int x, int y){
return x + y;
}
}
//출력값
7
7
7
7
람다식이 대입된 인터페이스 참조변수는 다음과 같이 accept()를 호출할 수 있다. 매개값으로 2와 5를 주면 람다식의 x 변수에 2, y 변수에 5가 대입되고 x와 y는 중괄호에서 사용된다.
메서드 레퍼런스
메서드 참조는 람다식에서 불필요한 매개변수를 제거할 때 주로 사용한다.
람다식으로 더욱 간단해진 익명 객체를 더욱더 간단하게 사용하고 싶은 개발자의 요구가 반영된 산물이라 할 수 있다.
정적 메서드와 인스턴스 메서드 참조
정적 메서드를 참조할 때는 클래스 이름 뒤에 :: 기호를 붙이고 정적 메서드 이름을 기술하면 된다.
클래스 :: 메서드
인스턴스 메서드의 경우에는 먼저 객체를 생성한 다음 참조 변수 뒤에 ::기호를 붙이고 인스턴스 메서드 이름을 기술하면 된다.
참조 변수 :: 메서드
아래 코드 예시는 Calculator의 정적 및 인스턴스 메서드를 참조하는 예시다.
//Calculator.java
public class Calculator {
public static int staticMethod(int x, int y) {
return x + y;
}
public int instanceMethod(int x, int y) {
return x * y;
}
}
import java.util.function.IntBinaryOperator;
public class MethodReferences {
public static void main(String[] args) throws Exception {
IntBinaryOperator operator;
/*정적 메서드
클래스이름::메서드이름
*/
operator = Calculator::staticMethod;
System.out.println("정적메서드 결과 : " + operator.applyAsInt(3, 5));
/*인스턴스 메서드
인스턴스명::메서드명
*/
Calculator calculator = new Calculator();
operator = calculator::instanceMethod;
System.out.println("인스턴스 메서드 결과 : "+ operator.applyAsInt(3, 5));
}
}
/*
정적메서드 결과 : 8
인스턴스 메서드 결과 : 15
*/
생성자 참조
메서드 참조는 생성자 참조도 포함한다.
생성자를 참조한다는 것은 객체 생성을 의미한다. 단순히 메서드 호출로 구성된 람다식을 메서드 참조로 대치할 수 있듯이, 단순히 객체를 생성하고 리턴하도록 구성된 람다식은 생성자 참조로 대치할 수 있다.
(a,b) -> new 클래스(a,b)
이 경우 생성자 참조로 표현하면 다음과 같다. 클래스 이름 뒤에 :: 기호를 붙이고 new 연산자를 기술하면 된다.
//생성자 참조 문법
클래스 :: new
생성자가 오버로딩되어 여러 개가 있으면 컴파일러는 함수형 인터페이스의 추상 메서드와 동일한 매개 변수 타입과 개수가 있는 생성자를 찾아 실행한다. 만약 해당 생성자가 존재하지 않으면 컴파일 오류가 발생한다.
아래 코드 예시는 생성자 참조를 이용해 두 가지 방법으로 Member 객체를 생성하는 예시이다.
//Member.java
public class Member {
private String name;
private String id;
public Member() {
System.out.println("Member() 실행");
}
public Member(String id) {
System.out.println("Member(String id) 실행");
this.id = id;
}
public Member(String name, String id) {
System.out.println("Member(String name, String id) 실행");
this.id = id;
this.name = name;
}
public String getName() {
return name;
}
public String getId() {
return id;
}
}
import java.util.function.BiFunction;
import java.util.function.Function;
public class ConstructorRef {
public static void main(String[] args) throws Exception {
Function<String, Member> function1 = Member::new;
Member member1 = function1.apply("kimcoding");
BiFunction<String, String, Member> function2 = Member::new;
Member member2 = function2.apply("kimcoding", "김코딩");
}
}
/*
Member(String id) 실행
Member(String name, String id) 실행
*/
하나는 Function<String, Member> 함수형 인터페이스의 Member apply(String) 메서드를 이용해서 Member 객체를 생성하고, 다른 하나는 BiFunction<String, String, Member> 함수형 인터페이스의 Member 객체를 생성한다.
스트림(Stream)
스트림(Stream)
스트림(Stream)은 배열, 컬렉션의 저장 요소를 하나씩 참조해서 람다식으로 처리할 수 있도록 해주는 반복자이다.
스트림을 사용하면 List, Set, Map, 배열 등 다양한 데이터 소스로부터 스트림을 만들 수 있고, 이를 표준화된 방법으로 다룰 수 있다.
스트림(Stream)의 도입 배경
자바의 배열과 컬렉션에 관한 내용을 학습하면서, 자료구조를 통해 많은 수의 데이터를 좀 더 효과적으로 다룰 수 있다는 사실을 이해할 수 있었다. 이렇게 저장된 데이터들에 반복적으로 접근하여 우리가 원하는 모양대로 데이터로 가공하기 위해 for 문 과 Iterator를 활용해 왔다.
하지만, 이렇게 기존 방식대로 데이터를 처리하는 데에는 크게 2가지 한계가 있다.
1. for 문 이나 Iterator를 사용하는 경우, 많은 경우 코드가 길고 복잡해 진다.
예를 들어 Iterator를 사용하여 반복처리 하는 코드 예시를 보자.
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
public class PrintNumberOperator {
public static void main(String[] args) {
// 각 숫자를 배열화
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
// Iterator 생성
Iterator<Integer> it = list.iterator();
// 리스트를 순회하며 값 출력
while (it.hasNext()) {
int num = it.next();
System.out.print(num);
}
}
}
//출력값
12345
이번엔 스트림을 사용하여 반복처리 하는 코드 예시를 보자.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public class PrintNumberOperatorByStream {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = list.stream();
stream.forEach(System.out::print);
}
}
//출력값
12345
Iterator 코드예시보다 조금 더 짧아 보이긴 하나 크게 차이가 나지 않아보인다.
하지만, 작성해야 하는 로직이 더 길어지고 복잡해진다면, 스트림을 사용한 코드의 작성은 분명한 효과를 가져온다.
추가적으로, 스트림을 사용하면 선언형 프로그래밍(Declarative Programming) 방식으로 데이터를 처리할 수 있어 더욱 인간 친화적이고 직관적인 코드 작성이 가능하다.
어떤 주어진 과업을 달성하기 위해 코드 한 줄 한 줄의 동작 원리를 이해하고 순차적이고 세세하게 이를 규정하는 방식을 명령형 프로그래밍(Imperative Programming)이라고 한다. 이러한 방식은 “어떻게” 코드를 작성할지에 대한 내용에 초점을 두고있다.
반면, 명령형 프로그래밍과 다르게 선언형 프로그래밍은 “어떻게”가 아닌 “무엇”에 집중하여 코드를 작성하는 코드 작성 방법론을 의미한다. 즉, 내부의 동작 원리를 모르더라도 어떤 코드가 어떤 역할을 하는지 직관적으로 이해할 수 있다. 여기서 “어떻게”에 대한 부분은 추상화되어 있다.
두 코드의 차이를 코드 예시를 통해 확인해보자.
명령형 프로그래밍 방식
import java.util.List;
public class ImperativeProgramming {
public static void main(String[] args){
// List에 있는 숫자 중에서 4보다 큰 짝수의 합계 구하기
List<Integer> numbers = List.of(1, 3, 6, 7, 8, 11);
int sum = 0;
for(int number : numbers){
if(number > 4 && (number % 2 == 0)){
sum += number;
}
}
System.out.println("명령형 프로그래밍을 사용한 합계 : " + sum);
}
}
//출력값
명령형 프로그래밍을 사용한 합계 : 14
위의 코드 예시는 “어떻게”에 초점을 둔 명령형 프로그래밍으로 조건에 부합하는 리스트의 합계를 구하는 코드이다.
선언형 프로그래밍 방식
import java.util.List;
public class DeclarativePrograming {
public static void main(String[] args){
// List에 있는 숫자들 중에서 4보다 큰 짝수의 합계 구하기
List<Integer> numbers = List.of(1, 3, 6, 7, 8, 11);
int sum =
numbers.stream()
.filter(number -> number > 4 && (number % 2 == 0))
.mapToInt(number -> number)
.sum();
System.out.println("선언형 프로그래밍을 사용한 합계 : " + sum);
}
}
//출력값
선언형 프로그래밍을 사용한 합계 : 14
위의 코드는 스트림을 사용하여 선언형으로 프로그래밍 방식을 바꿔서 표현했다.
스트림을 사용하면, 데이터 소스가 무엇이냐에 관계없이 같은 방식으로 데이터를 가공/처리할 수 있다.
다른 말로, 배열이냐 컬렉션이냐에 관계없이 하나의 통합된 방식으로 데이터를 다룰 수 있게 되었다는 뜻이다.
아래 코드 예시로 확인해보자.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public class StreamOperator {
public static void main(String[] args) {
// ArrayList
List<String> fruitList = new ArrayList<>();
fruitList.add("바나나 ");
fruitList.add("사과 ");
fruitList.add("오렌지 ");
// 배열
String[] fruitArray = {"바나나 ", "사과 ", "오렌지 "};
// 각각 스트림 생성
Stream<String> ListStream = fruitList.stream();
Stream<String> ArrayStream = Arrays.stream(fruitArray);
// 출력
ListStream.forEach(System.out::print);
ArrayStream.forEach(System.out::print);
}
}
//출력값
바나나 사과 오렌지 바나나 사과 오렌지
위의 코드 예시는 스트림을 사용하여 각기 다른 데이터 소스를 동일한 방식으로 처리하는 방법을 보여준다.
문자열을 요소로 가지는 List와 문자열 배열에 각각 스트림을 생성하고 forEach()메서드를 사용해서 각 요소를 순회하여 출력해주고 있다.
스트림의 특징
핵심적으로 기억해야 하는 스트림의 4 가지의 특징들은 다음과 같다.
- 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다.
- 스트림은 원본 데이터 소스를 변경하지 않는다(read-only).
- 스트림은 일회용이다(onetime-only).
- 스트림은 내부 반복자이다.
1. 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다
스트림을 바르게 이해하기 위해선 스트림 파이프 라인(stream pipeline)에 대한 이해가 필수적이다.
스트림 파이프라인은 1) 스트림의 생성, 2) 중간 연산, 3) 최종 연산이라는 총 세 가지 단계로 구성되어 있다.
중간 연산을 생략하고 곧바로 최종연산으로 넘어가는 두 단계 구성도 가능하다.
1) 스트림 생성
배열, 컬렉션, 임의의 수 등 다양한 데이터 소스를 일원화하여 스트림으로 작업하기 위해서는 스트림을 생성해야 한다.
스트림이 생성되고 나면, 최종 처리를 위한 중간 연산을 수행할 수 있다.
2) 중간 연산
여기에는 필터링, 매핑, 정렬 등의 작업이 포함되며, 중간 연산의 결과는 또 다른 스트림이기 때문에 계속 연결해서 연산을 수행할 수 있다.
이렇게 연결된 모양새가 마치 파이프라인과 같다고 해서 이러한 구조를 스트림 파이프라인이라고 한다.
3) 최종 연산
이렇게 중간 연산이 완료된 스트림을 최종적으로 처리하는 최종 연산(총합, 평균, 카운팅 등)을 끝으로 스트림은 닫히고 모든 데이터 처리가 완료된다.
최종 연산의 경우는 스트림의 요소를 소모하면서 연산을 수행하기 때문에 최종적으로 단 한 번의 연산만 가능하다.
따라서 최종 연산 후에 다시 데이터를 처리하고 싶다면, 다시 스트림을 생성해주어야 한다.
위의 그림은 남성과 여성으로 구성된 어떤 회원 컬렉션을 스트림을 사용하여 의도한 데이터로 가공하는 과정을 보여주는 스트림 파이프라인이다.
가장 먼저 스트림을 생성하고, 생성한 스트림에서 중간 연산 단계로 성별이 남자인 회원만 필터링한 후에, 그중에서 나이 요소만을 매핑한 후, 최종 연산을 통해 남자 회원들의 나이 평균을 구했다.
2. 스트림은 원본 데이터 소스를 변경하지 않는다(read-only).
스트림은 그 원본이 되는 데이터 소스의 데이터들을 변경하지 않는다.
오직 데이터를 읽어올 수 있고, 데이터에 대한 변경과 처리는 생성된 스트림 안에서만 수행된다. 이는 원본 데이터가 스트림에 의해 임의로 변경되거나 데이터가 손상되는 일을 방지하기 위함이다.
3. 스트림은 일회용이다(onetime-only).
스트림은 일회용이다. 다르게 표현하면, 스트림이 생성되고 여러 중간 연산을 거쳐 마지막 연산이 수행되고 난 후에는 스트림은 닫히고 다시 사용할 수 없다.
만약 추가적인 작업이 필요하다면, 다시 스트림을 생성해야 한다. 마치 컬렉션에서 배웠던 Iterator와 비슷하다고 할 수 있다.
4. 스트림은 내부 반복자이다.
내부 반복자(Internal Iterator)를 이해하기 위해서 먼저 이에 반대되는 개념인 외부 반복자(External Iterator)를 알면 도움이 된다. 외부 반복자란 개발자가 코드로 직접 컬렉션의 요소를 반복해서 가져오는 코드 패턴을 의미한다. 인덱스를 사용하는 for문, Iterator를 사용하는 while문 이 대표적이다.
반면 스트림은 반대로 컬렉션 내부에 데이터 요소 처리 방법(람다식)을 주입해서 요소를 반복처리 하는 방식이다.
외부 반복자의 경우 요소가 필요할 때마다 순차적으로 컬렉션에서 필요한 요소들을 불러오지만, 내부반복자는 데이터 처리 코드만 컬렉션 내부로 주입해 줘서 그 안에서 모든 데이터 처리가 이뤄지도록 한다.
내부반복자는 이처럼 더욱 효율적인 데이터 처리가 가능하다.
스트림의 생성
배열 스트림 생성
배열을 데이터 소스로 하는 스트림 생성은 Arrays 클래스의 stream() 메서드 또는 Stream 클래스의 of() 메서드를 사용할 수 있다.
Arrays.stream()
public class StreamCreator {
public static void main(String[] args) {
// 문자열 배열 선언 및 할당
String[] arr = new String[]{"김코딩", "이자바", "박해커"};
// 문자열 스트림 생성
Stream<String> stream = Arrays.stream(arr);
// 출력
stream.forEach(System.out::println);
}
}
// 출력값
김코딩
이자바
박해커
Stream.of()
import java.util.stream.Stream;
public class StreamCreator {
public static void main(String[] args) {
// 문자열 배열 선언 및 할당
String[] arr = new String[]{"김코딩", "이자바", "박해커"};
// 문자열 스트림 생성
Stream<String> stream = Stream.of(arr);
// 출력
stream.forEach(System.out::println);
}
}
// 출력값
김코딩
이자바
박해커
위 코드 예시들에서 Arrays.stream()와 Stream.of() 메서드 모두 같은 값을 출력하고 있다.
따라서, 배열로 스트림을 생성할 때에는 둘중 더 편한 메서드를 임의로 선택해 사용할 수 있다.
컬렉션 스트림 생성
컬렉션 타입(List, Set 등)의 경우, 컬렉션의 최상위 클래스인 Collection에 정의된 stream() 메서드를 사용하여 스트림을 생성할 수 있다.
Collection으로부터 확장된 하위클래스 List와 Set을 구현한 컬렉션 클래스들은 모두 stream() 메서드를 사용하여 스트림을 생성할 수 있다.
컬렉션 스트림 생성
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public class StreamCreator {
public static void main(String[] args) {
// 요소들을 리스트
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7);
Stream<Integer> stream = list.stream();
stream.forEach(System.out::print);
}
}
//출력값
1234567
위의 코드예시는 List 타입의 스트림을 생성하는 과정을 보여주는 예시다.
Arrays 클래스에 static 하게 선언된 asList() 메서드를 사용하여 요소들을 리스트 타입의 참조변수에 할당한 뒤에 stream() 메서드를 사용하여 스트림을 생성했다.
임의의 수 스트림 생성
난수를 생성하는 자바의 기본 내장 클래스 Random 클래스 안에는 해당 타입의 난수들을 반환하는 스트림을 생성하는 메서드들이 정의되어 있다.
예를 들면, int() 메서드의 경우 int 형의 범위 안에 있는 난수들을 무한대로 생성하여 IntStream 타입의 스트림으로 반환한다. 아래 코드를 인텔리제이에 입력하게 되면 int형의 출력밧이 무한대로 생성된다.
import java.util.Random;
import java.util.stream.IntStream;
public class StreamCreator {
public static void main(String[] args) {
// 난수 생성
IntStream ints = new Random().ints();
ints.forEach(System.out::println);
}
}
이렇게 스트림의 크기가 정해지지 않은 것을 무한 스트림(infinite stream)이라 부른다.
무한 스트림은 주로 limit() 메서드와 함께 사용하거나 매개변수로 스트림의 사이즈를 전달해서 그 범위를 제한할 수 있다.
import java.util.Random;
import java.util.stream.IntStream;
public class StreamCreator {
public static void main(String[] args) {
// 스트림 생성의 범위를 5개로 제한
IntStream ints = new Random().ints(5);
IntStream ints = new Random().ints().limit(5);
ints.forEach(System.out::println);
}
}
스트림의 중간 연산
스트림의 중간 연산자의 결과는 스트림을 반환하기 때문에 여러 개의 연산자를 연결하여 우리가 원하는 데이터 처리를 수행할 수 있다.
여러가지의 중간 연산자 중 가장 빈번하게 사용되는 필터링(filtering), 매핑(maping), 정렬(sorting) 을 알아보자.
먼저 구체적인 내용을 확인하기 전에, 전체적인 코드 구조를 파악해야 한다.
위의 코드는 앞서 설명했던 스트림 파이프라인을 코드화 하여 보여주고 있다.
스트림의 핵심 개념과 특징에서 살펴본 것처럼, 최초에 데이터 소스를 가지고 스트림을 생성한 후에 중간 연산자로 데이터를 가공하고, 최종 연산자를 통해서 스트림 작업을 종료한다.
이제 중간 연사자들을 확인해보자.
필터링(filter() , distinct() )
필터링은 이름 그대로 우리의 필요에 따라 조건에 맞는 데이터들만을 정제하는 역할을 하는 중간 연산자를 가리킨다.
- distinct() : Stream의 요소들에 중복된 데이터가 존재하는 경우, 중복을 제거하기 위해 사용합니다.
- filter(): Stream에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어냅니다. filter() 메서드에는 매개값으로 조건(Predicate)을 주고, 조건이 참이 되는 요소만 필터링합니다. 여기서 조건은 람다식을 사용하여 정의할 수 있습니다.
import java.util.Arrays;
import java.util.List;
public class FilteringExample {
public static void main(String[] args) throws Exception {
List<String> names = Arrays.asList("김코딩", "이자바", "박해커", "김코딩", "박해커");
names.stream()
.distinct() //중복 제거
.forEach(element -> System.out.println(element));
System.out.println();
names.stream()
.filter(element -> element.startsWith("김")) // 김씨 성을 가진 요소만 필터링
.forEach(element -> System.out.println(element));
System.out.println();
names.stream()
.distinct() //중복제거
.filter(element -> element.startsWith("김")) // 김씨 성을 가진 요소만 필터링
.forEach(element -> System.out.println(element));
}
}
// 출력값
김코딩
이자바
박해커
김코딩
김코딩
김코딩
위의 코드 예시에서 필터링의 과정을 살펴보면,
첫 번째 스트림에서는 중복 제거 중간 연산만을 수행하여 리스트 타입의 참조변수 names 안에 중복되고 있는 “김코딩” 요소를 제거한다.
두 번째 스트림에서는 “김”씨 성을 가진 요소들만 필터링하여 “김코딩”을 두 번 출력해준다.
첫 번째와 두 번째 스트림은 각각 독립적인 스트림이다. 대표적 최종 연산자인 forEach()로 첫 번째 스트림이 닫히고 난 후 두 번째 스트림이 생성되어 새로운 연산을 수행하고 있다.
마지막 세 번째 스트림은 중복제거와 필터링을 모두 수행하고, 그 결과로 “김코딩”이라는 문자열을 단 한 번 출력해주고 있다.
매핑(map())
매핑은 스트림 내 요소들에서 원하는 필드만 추출하거나 특정 형태로 변환할 때 사용하는 중간 연산자이다.
filter() 메서드와 마찬가지로 값을 변환하기 위한 조건을 람다식으로 정의한다.
import java.util.Arrays;
import java.util.List;
public class IntermediateOperationExample {
public static void main(String[] args) {
List<String> names = Arrays.asList("kimcoding", "javalee", "hackerna", "luckyguy");
names.stream()
.map(element -> element.toUpperCase()) // 요소들을 하나씩 대문자로 변환
.forEach(element->System.out.println(element));
}
}
// 출력값
KIMCODING
JAVALEE
HACKERNA
LUCKYGUY
위의 코드 예시는 리스트 타입의 참조변수 names 안에 정의된 각 요소를 순회하면서 소문자 이름을 대문자로 변환한 값들이 담긴 스트림으로 반환하는 연산 과정을 보여주고 있다.
또한, 다음과 같이 각 요소에 어떤 연산을 실행하고 난 후의 값을 반환받을 수 있다.
아래의 코드 예시는 각 요소에 값에 3을 곱한 값을 스트림으로 반환하여 출력하는 과정을 보여주고 있다.
import java.util.Arrays;
import java.util.List;
public class IntermediateOperationExample {
public static void main(String[] args)
{
List<Integer> list = Arrays.asList(1, 3, 6, 9);
// 각 요소에 3을 곱한 값을 반환
list.stream().map(number -> number * 3).forEach(System.out::println);
}
}
// 출력값
3
9
18
27
map()과 함께 많이 사용되는 flatMap() 중간 연산자도 있다.
만약에 아래와 같은 이중 배열이 있고, 그 안의 배열을 위에서 학습한 map() 메서드를 사용하여 하나씩 출력해 주는 프로그램을 만들어야 한다고 가정해보자.
// 주어진 이중 배열
String[][] namesArray = new String[][]{{"박해커", "이자바"}, {"김코딩", "나박사"}};
// 기대하는 출력값
박해커
이자바
김코딩
나박사
map()을 사용하여 기대하는 값을 출력해 본다.
// map() 사용
Arrays.stream(namesArray)
.map(inner -> Arrays.stream(inner))
.forEach(System.out::println);
// 출력값
java.util.stream.ReferencePipeline$Head@3cb5cdba
java.util.stream.ReferencePipeline$Head@56cbfb61
출력값은 기대값이 아닌 스트림의 객체의 값을 반환한다.
스트림의 객체의 값을 반환하는 이유는 위의 연산에서 map() 메서드는 Stream<Stream<String>> 즉 중첩 스트림을 반환하고 있기 때문이다.
여기서 원하는 결과 값을 출력하기 위해서는 반환타입이 Stream<Stream<String>>이 아닌 Stream<String>이 되어야 한다.
그럼 반환 타입이 Stream<String>이 되도록 하려면 어떻게 해야 할까?
첫 번째는 기존의 방식을 조금 수정하는 것이다.
기존 방식의 수정
// map 사용
Arrays.stream(namesArray)
.map(inner -> Arrays.stream(inner))
.forEach(names -> names.forEach(System.out::println));
// 출력값
박해커
이자바
김코딩
나박사
위의 코드 예시 처럼 forEach() 메서드 안의 람다식의 정의에서, 각 요소에 대하여 다시 forEach() 메서드를 출력함으로써 뎁스가 있는 요소들에 접근하여 이를 출력할 수 있다.
다만, 지금처럼 이중구조가 아닌 3중, 4중, 5중으로 깊어지는 경우에는 매우 번거롭고 가독성이 떨어지는 코드 작성이 될 것이다.
이런 경우, flatMap()을 활용한다.
- flatMap()
// flatMap()
Arrays.stream(namesArray).flatMap(Arrays::stream).forEach(System.out::println);
// 출력값
박해커
이자바
김코딩
나박사
flatMap() 은 중첩 구조를 제거하고 단일 컬렉션(Stream<String>)으로 만들어주는 역할을 한다.
이를 요소들을 “평평하게”한다는 의미에서 플래트닝(flattening)이라고 한다.
이처럼, 위와 같이 배열 요소들의 뎁스가 있는 작업들을 수행할 때 flatMap() 메서드를 활용하면 훨씬 간편하고 효과적으로 같은 작업을 수행할 수 있다.
정렬(sorted())
sorted() 메서드는 정렬할 때 사용하는 중간 연산자다.
sorted() 메서드를 사용하여 정렬할 때는 괄호(()) 안에 Comparator라는 인터페이스에 정의된 static 메서드와 디폴트 메서드를 사용하여 간편하게 정렬 작업을 수행할 수 있다.
괄호 안에 아무 값도 넣지 않은 상태로 호출하면 기본 정렬(오름차순)로 정렬된다.
기본 정렬
import java.util.Arrays;
import java.util.List;
public class IntermediateOperationExample {
public static void main(String[] args) {
// 동물들의 이름을 모아둔 리스트
List<String> animals = Arrays.asList("Tiger", "Lion", "Monkey", "Duck", "Horse", "Cow");
// 인자값 없는 sort() 호출
animals.stream().sorted().forEach(System.out::println);
}
}
// 출력값
Cow
Duck
Horse
Lion
Monkey
Tiger
역순으로 정렬
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
public class IntermediateOperationExample {
public static void main(String[] args) {
List<String> animals = Arrays.asList("Tiger", "Lion", "Monkey", "Duck", "Horse", "Cow");
// 인자값에 Comparator 인터페이스에 규정된 메서드 사용
animals.stream().sorted(Comparator.reverseOrder()).forEach(System.out::println);
}
}
// 출력값
Tiger
Monkey
Lion
Horse
Duck
Cow
Comparator 인터페이스 안에 정의된 reverseOrder()라는 이름의 메서드를 인자로 넘겨 호출하는 것만으로 손쉽게 역순으로 정렬하는 기능을 실행할 수 있다.
기타 중간 연산자
나머지 중간 연산자들을 간단하게 확인해보자.
- skip() - 스트림의 일부 요소들을 건너뛴다.
- limit() - 스트림의 일부를 자른다.
- peek() - forEach()와 마찬가지로, 요소들을 순회하며 특정 작업을 수행한다.
- peek() 와 forEach()의 차이점은 peek()는 중간 연산자로 여러 번 연결하여 사용가능하지만, forEach()는 최종연산자이기 때문에 마지막에 단 한번만 사용가능하다.
스트림의 최종 연산
기본 집계(sum() , count() , average(), max() , min())
import java.util.Arrays;
public class TerminalOperationExample {
public static void main(String[] args) {
// int형 배열 생성
int[] intArray = {1,2,3,4,5};
// 카운팅
long count = Arrays.stream(intArray).count();
System.out.println("intArr의 전체 요소 개수 " + count);
// 합계
long sum = Arrays.stream(intArray).sum();
System.out.println("intArr의 전체 요소 합 " + sum);
// 평균
double average = Arrays.stream(intArray).average().getAsDouble();
System.out.println("전체 요소의 평균값 " + average);
// 최대값
int max = Arrays.stream(intArray).max().getAsInt();
System.out.println("최대값 " + max);
// 최소값
int min = Arrays.stream(intArray).min().getAsInt();
System.out.println("최소값 " + min);
// 배열의 첫 번째 요소
int first = Arrays.stream(intArray).findFirst().getAsInt();
System.out.println("배열의 첫 번째 요소 " + first);
}
}
// 출력값
intArr의 전체 요소 개수 5
intArr의 전체 요소 합 15
전체 요소의 평균값 3.0
최대값 5
최소값 1
배열의 첫 번째 요소 1
매칭(allMatch(), anyMatch(), noneMatch() )
match() 메서드를 사용하면 조건식 람다 Predicate를 매개변수로 넘겨 스트림의 각 데이터 요소가 특정한 조건을 충족하는지 않는지 검사하여, 그 결과를 boolean 값으로 반환한다.
match() 메서드는 크게 다음의 3가지 종류가 있다.
- allMatch() - 모든 요소가 조건을 만족하는지 여부를 판단합니다.
- noneMatch() - 모든 요소가 조건을 만족하지 않는지 여부를 판단합니다.
- anyMatch() - 하나라도 조건을 만족하는 요소가 있는지 여부를 판단합니다
매칭
import java.util.Arrays;
public class TerminalOperationExample {
public static void main(String[] args) throws Exception {
// int형 배열 생성
int[] intArray = {2,4,6};
// allMatch()
boolean result = Arrays.stream(intArray).allMatch(element-> element % 2 == 0);
System.out.println("요소 모두 2의 배수인가요? " + result);
// anyMatch()
result = Arrays.stream(intArray).anyMatch(element-> element % 3 == 0);
System.out.println("요소 중 하나라도 3의 배수가 있나요? " + result);
// noneMatch()
result = Arrays.stream(intArray).noneMatch(element -> element % 3 == 0);
System.out.println("요소 중 3의 배수가 하나도 없나요? " + result);
}
}
// 출력값
요소 모두 2의 배수인가요? true
요소 중 하나라도 3의 배수가 있나요? true
요소 중 3의 배수가 하나도 없나요? false
요소 소모(reduce())
reduce() 최종 연산자는 스트림의 요소를 줄여나가면서 연산을 수행하고 최종적인 결과를 반환한다.
reduce() 메서드의 경우에는 먼저 첫 번째와 두 번째 요소를 가지고 연산을 수행하고, 그 결과와 다음 세 번째 요소를 가지고 또다시 연산을 수행하는 식으로 연산이 끝날 때까지 반복한다.
reduce() 메서드의 매개변수 타입은 앞서 람다와 관련 함수형 인터페이스에서 배웠던 BinaryOperator<T> 로 정의되어 있다.
reduce() 메서드는 최대 3개까지 매개변수를 받을 수 있다. 여기서 우리는 2개를 받는 경우 까지만 학습한다.
T reduce(T identity, BinaryOperator<T> accumulator)
위에서 첫 번째 매개변수 identity는 특정 연산을 시작할 때 설정되는 초기값을 의미한다.
두 번째 accumulator는 각 요소를 연산하여 나온 누적된 결과값을 생성하는 데 사용하는 조건식이다.
reduce()
import java.util.Arrays;
public class TerminalOperationExample {
public static void main(String[] args) throws Exception {
int[] intArray = {1,2,3,4,5};
// sum()
long sum = Arrays.stream(intArray).sum();
System.out.println("intArray 전체 요소 합: " + sum);
// 초기값이 없는 reduce()
int sum1 = Arrays.stream(intArray)
.map(element -> element * 2)
.reduce((a , b) -> a + b)
.getAsInt();
System.out.println("초기값이 없는 reduce(): " + sum1);
// 초기값이 있는 reduce()
int sum2= Arrays.stream(intArray)
.map(element -> element * 2)
.reduce(5, (a ,b) -> a + b);
System.out.println("초기값이 있는 reduce(): " + sum2);
}
}
// 출력값
intArray 전체 요소 합: 15
초기값이 없는 reduce(): 30
초기값이 있는 reduce(): 35
첫 번째 예시는 sum() 메서드를 활용하여 숫자 요소들의 총합을 도출하는 스트림 작업이다.
1+2+3+4+5의 결과로 숫자 15 가 나왔다.
두 번째와 세 번째는 모두 reduce() 메서드를 사용하고 있지만, 세 번째의 경우에는 초기값으로 5 가 설정되어 있기 때문에 최종적인 연산의 결과가 두 번째 예제보다 5 가 더 많은 20 이 출력된다.
위 배열의 값을 사용하여 조금 더 구체적인 흐름을 살펴보면 다음과 같다.
- accumulator: (a, b) -> a + b (a: 누적된 값, b: 새롭게 더해질 값)
- 최초 연산 시 1+2 → a: 3, b: 3
- 3+3 → a: 6, b: 4
- 6+4 → a: 10, b: 5
- 10+5 → 최종 결과:15
요소 수집(collect())
스트림은 중간 연산을 통한 요소들의 데이터 가공 후 요소들을 수집하는 최종 처리 메서드인 collect()를 지원한다.
스트림의 요소들을 List, Set, Map 등 다른 타입의 결과로 수집하고 싶은 경우에 collect() 메서드를 유용하게 사용할 수 있다.
collect() 메서드는 Collector 인터페이스 타입의 인자를 받아서 처리할 수 있는데, 직접 구현하거나 미리 제공된 것들을 사용할 수 있다
collect()
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class TerminalOperationExample {
public static void main(String[] args) {
// Student 객체로 구성된 배열 리스트 생성
List<Student> totalList = Arrays.asList(
new Student("김코딩", 100, Student.Gender.Male),
new Student("박해커", 80, Student.Gender.Male),
new Student("이자바", 90, Student.Gender.Female),
new Student("나미녀", 60, Student.Gender.Female)
);
// 스트림 연산 결과를 Map으로 반환
Map<String, Integer> maleMap = totalList.stream()
.filter(s -> s.getGender() == Student.Gender.Male)
.collect(Collectors.toMap(
student -> student.getName(), // Key
student -> student.getScore() // Value
));
// 출력
System.out.println(maleMap);
}
}
class Student {
public enum Gender {Male, Female};
private String name;
private int score;
private Gender gender;
public Student(String name, int score, Gender gender) {
this.name = name;
this.score = score;
this.gender = gender;
}
public String getName() {
return name;
}
public int getScore() {
return score;
}
public Gender getGender() {
return gender;
}
}
// 출력값
{김코딩=100, 박해커=80}
4개의 Student 타입의 객체로 이뤄진 리스트 배열을 만들었다. “김코딩”과 “김인기”라는 이름의 남성 2명과 “이자바”와 “나미녀”라는 이름의 여성 2명이 있다.
이렇게 리스트 배열로 이뤄진 데이터 요소들에서 남학생들의 이름과 점수만을 추출하여 이름과 점수를 각각 키(key)와 값(value)으로 하는 Map 타입의 결과를 수집하고자 할 때 collect() 메서드가 위의 코드처럼 유용하게 사용될 수 있다.
흐름을 보면, 먼저 리스트 배열에 스트링을 생성하고, 중간 연산자 filter() 메서드를 통해 성별이 남자인 학생들만 필터링한 후, 마지막으로 최종 연산자 collect()에 Collectors 클래스 안에 정의된 정적 메서드(toMap())를 사용하면 아래 출력값과 같이 우리가 의도했던 대로 Map 타입의 결과물을 받아볼 수 있다.
'코드 스테이츠' 카테고리의 다른 글
코드 스테이츠 - Java 심화(Effective) 3 (0) | 2023.05.08 |
---|---|
코드스테이츠 - Java 심화 (Effective)2 (0) | 2023.05.04 |
코드 스테이츠 - Java 컬렉션(Collection) (0) | 2023.05.01 |
코드 스테이츠 4/26 - 객체지향 프로그래밍 심화 2 (0) | 2023.04.26 |
코드 스테이츠 04/25 - 객체 지향 프로그래밍 심화1 (1) | 2023.04.25 |