숑숑이의 개발일기
Published 2023. 8. 10. 23:36
[Java] #18 람다식 Backend/JAVA

본글은 'Do it 자바 완전 정복' 책을 통한 공부내용을 정리한 글입니다.

 

람다식

람다식은 객체 지향형 언어인 자바에서 함수형 프로그래밍 방식을 쓸 수 있게 해주는 문법 요소다. 자바에서 람다식은 자바 8버전부터 사용할 수 있다. 자바에서 메서드를 사용하려면 항상 클래스 객체를 먼저 생성한 후 생성한 객체로 메서드를 호출해야 한다. 그런데 이 공통 기능을 일반적인 함수처럼 독립적으로 만든 후 모든 클래스에서 공통적으로 사용할 수 있다면 훨씬 효율적일 것이다.

 

하지만 자바는 객체 지향 언어이므로 외부에 독립적으로 함수를 구성할 수 없다. 항상 클래스 내부에 메서드로 존재해야 하고, 메서드를 사용하기 위해서는 클래스의 객체를 먼저 생성한 후에 호출해야 한다.

 

이를 해결하기 위해 나온 방법이 '람다식'이다. 자바는 새로운 함수 문법을 정의하는 대신, 이미 있는 인터페이스의 문법을 활용해 람다식을 표현한다. 단 하나의 추상 메서드만을 포함하는 인터페이스함수형 인터페이스라 하고, 이 함수형 인터페이스의 호출 및 기능을 구현하는 방법을 새롭게 정의한 문법람다식이다.

 

정리해, 람다식은 기존의 객체 지향 프로그램 체계 안에서 함수형 프로그래밍을 가능하게 하는 기법이라 생각할 수 있다.

 

객체 지향 구조 내에서 람다식 적용 과정

먼저, 함수형 인터페이스를 상속받아 추상 메서드를 구현한 객체를 생성한 후 메소드를 호출하는 과정을 기존 방법과 람다식을 이용한 방법을 비교해 나타내본다.

interface A {
	void abc();
}

class B implements A {
	@Override
    public void abc() {
    	System.out.println("메서드 1");
    }
}

public class OOPvsFP {
	public static void main(String[] args) {
    	// 1. 객체 지향 프로그래밍 문법1
        A a1 = new B();
        a1.abc();	// 메서드 1
        
        // 2. 객체 지향 프로그래밍 문법 2(익명 이너 클래스 사용)
        A a2 = new A() {
        	@Override
            public void abc() {
            	System.out.println("메서드 2");	// 메서드 2
            }
        }
        a2.abc();
        
        // 3. 함수형 프로그래밍 문법(람다식)
        A a3 = () => {System.out.println("메서드 3");}	
        a3.abc();	// 메서드 3
    }
}

람다식은 익명 이너 클래스 정의 내부에 있는 메서드명을 포함해 이전 부분을 삭제한 형태다. 즉, 익명 이너 클래스의 축약된 형태라고 볼 수 있다.

 

람다식의 기본 문법 및 약식 표현

람다식의 기본 문법

이제 클래스 내부에서 구현한 함수형 인터페이스 메서드 정의를 람다식으로 변환하는 문법에 대해 알아보자. 구현된 추상 메서드를 람다식으로 표현할 때는 메서드 명 이후의 소괄호와 중괄호만을 차례대로 포함하며, 이들 사이에는 람다식 기호인 화살표(->)가 들어간다.

 

소괄호는 입력매개변수, 중괄호는 메서드의 내용을 나타내기 때문에 람다식은 입력매개변수에 따른 메서드의 기능만을 정의한다고 생각하면 된다.

void method1() {
	System.out.println(3);
}

() -> {System.out.println(3);}

void method2(int a) {
	System.out.println(a);
}

(int a) -> {System.out.println(a);}

int method3() {
	return 5;
}

() -> {return 5;}

double method4(int a, double b) {
	return a + b;
}

(int a, double b) -> {
	return a + b;
}

 

람다식의 약식 표현

람다식은 특정 조건에서 더욱 축약해 표현할 수 있다. 

 

1) 중괄호 안의 실행문이 1개일 때 중괄호는 생략할 수 있다.

A a1 = () -> {System.out.println("테스트");};
A a2 = () -> System.out.println("테스트");

 

2) 매개변수 타입을 생략 가능하고, 매개변수가 1개일 때에는 소괄호를 생략할 수 있다.

A a1 = (int a) -> {...};
A a2 = (a) -> {...};

A a3 = a -> {...};
A a4 = int a -> {...};	// x
// 소괄호가 생략될 때에는 매개변수 타입을 반드시 생략한다.

 

3) 메서드가 return 구문만 포함할때 return 생략 할 수 있다.

A a1 = (int a, int b) -> {return a + b;};
A a2 = (int a, int b) -> a + b;
A a3 = (int a, int b) -> {a + b;}	// x
// return을 생략할 때에는 중괄호도 반드시 생략한다.

 

람다식의 활용

람다식은 메서드 참조와 생성자 참조에도 사용된다. 여기서 참조한다는 의미는 함수형 인터페이스의 메서드를 구현하는 데 있어 직접 구현하는 대신, 이미 있는 기능을 가져다 쓰겠다는 의미다. 즉, 구현 메서드의 약식 표현은 함수형 인터페이스의 추상 메서드를 직접 구현해쓸 때, 메서드 참조 이미 있는 메서드로 대체했을 때다. 생성자 참조구현 메소드의 내용이 객체 생성 코드만으로 고정되어 있을 때다.

결국 람다식은 함수형 인터페이스의 추상 메서드를 어떤 방식으로 구현하느냐에 따라 3가지 형태를 띠는 것이다.

 

구현 메서드의 약식 표현

첫 번째 람다식의 활용 예는 함수형 인터페이스의 객체 생성 과정에서 익명 이너 클래스를 이용한 객체 생성 방식의 축약된 표현을 제공하는 것이다. 즉, 직접 추상 메서드를 구현하는 형태다.

interface A {
	void method1();
}

interface B {
	void method2(int a);
}

interface C {
	int method3();
}

interface D {
	double method4(int a, double b);
}

public class FunctionToLamda {
	public static void main(String[] args) {
    	// 인터페이스의 함수 구현 -> 람다식
        // 1. 입력 X, 리턴 X
        A a1 = () -> {System.out.println(1);}
        A a2 = () -> System.out.println(1);
        
        // 2. 입력 O, 리턴 X
        B b1 = (int a) -> {System.out.println(2);};
        B b2 = (a) -> {System.out.println(2);};
        B b3 = (a) -> System.out.println(2);
        B b1 = a -> System.out.println(2);
        
        // 3. 입력 X, 리턴 O
        C c1 = () -> {return 4};
        C c2 = () -> 4;
        
        // 4. 입력 O, 리턴 O
        D d1 = (int a, double b) -> {return a + b;};
        D d2 = (a, b) -> {return a + b;};
        D d3 = (a, b) -> a + b;
    }
}

 

메서드 참조

두 번째 활용 예는 추상 메서드를 직접 구현하는 대신, 이미 구현이 완료된 메서드를 참조하는 것이다. 메서드를 참조하는 방식은 다시 인스턴스 메서드를 참조할 때와 정적 메서드를 참조할 때로 나눠지고, 인스턴스 메서드의 참조는 다시 2가지로 나뉜다.

 

정의되어 있는 메서드 참조

이미 정의 되어있는 인스턴스 메서드를 참조하기 위해서는 당연히 객체를 먼저 생성해야 할 것이다. 객체 생성 이후 인스턴스 메서드를 참조하는 방법은 객체 참조 변수::인스턴스 메서드명과 같이 작성한다.

 

다음과 같이 추상 메서드 하나를 갖는 함수형 인터페이스 A와 인스턴스 메서드를 갖고있는 클래스 B가 정의되어 있을 때를 고려해보자.

interface A {
	void abc();
}

class B {
	void bcd() {
    	System.out.println("메서드");
    }
}

public class RefOfInstanceMethod1{
	public static void main(String[] args) {
    	// 1. 익명 이너 클래스
        A a1 = new A() {
        	@Override
            public void abc() {
            	B b = new B();
                b.bcd();
            }
        };
        
        // 2. 람다식으로 표현
        A a2 = () -> {
        	B b = new B();
            b.bcd();
        }
        
        // 3. 정의된 인스턴스 메서드 참조
        B b = new B();
        A a3 = b::bcd;
        // A 인터페이스 내부의 abc() 메서드는 참조 변수 b 객체 내부의 인스턴스 메서드 bcd와 동일하다.
        // abc()가 bcd()를 참조하기 위해서는 리턴타입과 매개변수 타입이 반드시 동일해야 한다.
        a1.abc();
        a2.abc();
        a3.abc();
    }
}

 

이러한 인스턴스 메서드 참조 방법을 이용해 지금까지 자주 사용한 메서드를 참조해 보자.

interface A {
	void abc(int k);
}

public class RefOfInstanceMethod2 {
	public void main(String[] args) {
    	// 1. 익명 이너 클래스
        A a1 = new A() {
        	@Override
            public void abc(int k) {
            	System.out.println(k);
            }
        }
        
        // 2. 람다식으로 표현
        A a2 = (int k) -> {System.out.println(k);};
        
        // 3. 인스턴스 메서드 참조
        A a3 = System.out::println;
        // System.out 자체가 객체이므로 객체를 따로 생성할 필요 없이 위와 같이 작성 가능
    }
}

 

정의되어 있는 정적 메서드 참조

두 번째는 이미 정의되어 있는 정적 메소드를 참조하는 방법이다. 클래스명::정적 메서드명으로 메서드를 참조한다. 정적 메서드는 객체 생성 없이 클래스명으로 바로 사용할 수 있기 때문에 클래스명을 바로 사용했다고 생각하면 된다. 정적 메서드 역시 메서드의 참조를 위해서 리턴타입과 입력매개변수 타입이 동일해야 한다.

 

interface A {
	void abc();
}

class B {
	static void bcd() {
    	System.out.println("메서드");
    }
}

public class RefOfStaticMethod{
	public static void main(String[] args) {
    	// 1. 익명 이너 클래스
        A a1 = new A() {
        	@Override
            public void abc() {
            	B.bcd();
            }
        };
        
        // 2. 람다식으로 표현
        A a2 = () -> {B.bcd();};
        
        // 3. 정적 메서드 참조
        A a3 = B::bcd;
    }
}

 

첫 번째 매개변수로 전달된 객체의 인스턴스 메서드 참조

세 번째 메서드 참조 방법은 첫 번째 인스턴스 메서드 참조 방법의 변형된 형태다. 추상 메서드의 구현 과정에서 첫 번째 매개변수로 인스턴스 메서드를 포함하고 있는 객체를 함께 전달하고, 이후 전달된 객체의 인스턴스 메서드를 포함하고 있는 객체의 인스턴스 메서드를 참조할 때다. 이때 람다식의 표현 방법은 클래스명::인스턴스 메서드명을 사용한다. 이때는 객체가 첫 번째 매개변수로 전달되므로 따로 생성하는 과정은 필요 없을 것이다.

 

의아한점은 메서드를 참조할 때 클래스명이 앞에 오면 정적 메서드를 호출 할 때라고 했는데 여기서도 클래스명이 사용된다. 즉, 앞에 클래스명이 나오면 정적 메서드 참조이거나 매개변수로 전달된 객체의 인스턴스 메서드를 참조하는 것이다. 해당 메서드가 정적 메서드인지는 static 키워드의 존재 여부로 쉽게 구분할 수 있다.

또 하나의 차이점은 정적 메서드 참조시에는 추상 메서드와 참조 메서드의 입력매개변수가 모두 동일하지만, 전달된 객체의 메서드를 참조할 때는 추상메서드의 첫 번째 매개변수로 객체가 들어가므로 참조 메서드보다 매개변수가 1개 많다는 것이다.

interface A {
	void abc(B b, int k);
}

 class B {
 	void bcd(int k) {
    	System.out.println(k);
    }
 }
 
 public class RefOfInstanceMethod4 {
 	public static void main(String[] args) {
    	// 익명 이너 클래스
        A a1 = new A() {
        	@Override
            public void abc(B b, int k) {
            	b.bcd(k);
            }
        };
        
        // 람다식
        A a2 = (B b, int k) -> {b.bcd(k);};
        
        // 직접 정의한 인스턴스 메서드 참조
        A a3 = B::bcd;
        // abc(B b, int k)의 메서드는 첫 번째 매개변수 내부의 bcd() 내부와 동일하다는 뜻
    }
 }

 

이번에는 자바에서 제공하는 클래스의 메서드를 이 방법으로 참조해보자.

interface A {
	int abc(String str);
}

public class RefOfInstanceMethod5 {
	public static void main(String[] args) {
    	// 1. 익명 이너 클래스
        A a1 = new A() {
        	@Override
            public int abc(String str) {
            	return str.length();
            }
        };
        
        // 2. 람다식
        A a2 = (String str) -> str.length();
        
        // 3. 자바가 제공하는 인스턴스 메서드 참조
        A a3 = String::length;
        System.out.println(a1.abc("안녕"));
        System.out.println(a2.abc("안녕"));
        System.out.println(a3.abc("안녕"));
    }
}

 

생성자 참조

람다식의 마지막 활용 방법은 생성자 참조다. 생성자 참조에서 참조하는 생성자는 크게 배열 객체 생성자와 클래스 객체 생성자로 나뉜다.

 

배열 생성자 참조

배열 타입 생성자를 참조한다는 의미는 함수형 인터페이스에 포함된 추상 메서드가 배열의 크기를 입력매개변수로 하며, 특정 배열 타입을 리턴한다면 구현 메서드의 내부에 반드시 new 자료형[]이 포함될 것이다. 이때 인터페이스에 포함된 추상 메서드의 구현 메서드가 배열 객체의 생성 기능만을 수행할 때는 람다식의 배열 생성자 참조 방법을 사용할 수 있다.

// 배열 생성자 참조
배열 타입::new
interface A {
	int[] abc(int len);
}

public class RefOfArrayConstructor {
	public static void main(String[] args) {
    	// 1. 익명 이너 클래스
        A a1 = new A() {
        	@Override
            public int[] abc(int len) {
            	return new int[len];
            }
        };
        
        // 2. 람다식
        A a2 = (int len) -> {
        	return new int[len];
        };
        
        // 3. 배열의 생성자 참조
        A a3 = int[]::new;
    }
}

 

클래스 생성자 참조

인터페이스의 추상 메서드가 클래스 타입의 객체를 리턴할 때도 배열과 마찬가지로 생성자 참조를 사용할 수 있다.

// 클래스 생성자 참조
클래스명::new
interface A {
	B abc();
}

class B {
	B() {
    	System.out.println("첫번째 생성자");
    }
    
    B(int k) {
    	System.out.println("두번째 생성자");
    }
}

public class RefOfConstructor {
	public static void main(String[] args) {
    	// 1. 익명 이너 클래스
        A a1 = new A() {
        	@Override
            public B abc() {
            	return new B();
            }
        };
        
        // 2. 람다식
        A a2 = () -> new B();
        
        // 3. 클래스 생성자 참조
        A a3 = B::new;
        // a.abc() 메서드를 호출하면 new B()를 실행해 객체를 생성하라
    }
}

여기까지는 배열의 참조와 동일한 개념이다. 클래스 생성자 참조에서 고려해야할 사항은 생성자가 여러개 일 수 있다는 것이다. 위의 예제에서는 클래스 B의 기본 생성자를 호출해 객체를 생성했다.  다른 생성자를 호출해 객체를 생성하고자 할 때에는 인터페이스 A에 포함된 추상 메서드의 매개변수에 따라 결정된다.

interface A {
	B abc(int k);
}

class B {
	B() {
    	System.out.println("첫번째 생성자");
    }
    B(int k) {
    	System.out.println("두번째 생성자");
    }
}

public class RefOfClassConstructor2 {
	public static void main(String[] args) {
    	// 1. 익명 이너 클래스
        A a1 = new A() {
        	@Override
            public B abc(int k) {
            	return new B(3);
            }
        };
        
        // 2. 람다식
        A a2 = (int k) -> new B(3);
        
        // 3. 클래스 생성자 참조
        A a3 = B::new;
        // 앞의 예제와 동일한 표현식인데도 abc(k)를 호출하면 new B(K)를 실행하라
    }
}
profile

숑숑이의 개발일기

@숑숑-

풀스택 개발자 준비중입니다