[Java] 내부 클래스와 람다식
중첩 클래스와 그 종류
클래스 안에 클래스를 선언할 수 있다. 이 때 안에 정의된 클래스를 중첩 클래스(nested class), 그리고 그 중첩 클래스를 감싸고 있는 클래스를 외부 클래스(outer class)라 한다. 중첩 클래스는 다음과 같이 구분할 수 있다.
- 스태틱 중첩 클래스
- 논스태틱 중첩 클래스
- 멤버 내부 클래스 (member inner class)
- 지역 내부 클래스 (local inner class)
- 익명 내부 클래스 (anonymous inner class)
이 때, 논스태틱 중첩 클래스를 내부 클래스(inner class)라 부른다. 스태틱 중첩 클래스의 경우, 클래스 내부에 static의 중첩 클래스를 정의한다는 것인데, 이것은 메모리 측면에서 보면 내부 클래스라고 하기가 어렵다. 코드 상으로는 분명 외부 클래스의 내부에 정의되었으나, 프로그램이 실행되기도 전에 스태틱 영역의 메모리 공간에 먼저 탑재되어 전역적으로 사용할 수 있기 때문이다. 반면 스태틱이 아닌 중첩 클래스는 외부 클래스를 먼저 인스턴스화 한 다음에 접근할 수 있으므로 내부 클래스라고 할 수 있다.
멤버 내부 클래스
외부 클래스 내에 선언한 클래스를 멤버 내부 클래스라 한다. 멤버 변수, 멤버 메서드처럼 정의하여 사용할 수 있어서 멤버 내부 클래스라 이름이 붙은 것 같다.
다음은 멤버 내부 클래스를 정의하고 사용해보는 예제이다.
class Factory {
String factoryName = "로봇조아";
class Robot {
public String goTo = "left";
void moveTo() {
System.out.printf("로봇이 %s 방향으로 움직입니다.\n", goTo);
}
void printMadeIn() {
// 외부 클래스의 멤버 변수에 접근 가능.
System.out.printf("해당 로봇은 %s 회사에서 제작되었습니다.\n", factoryName);
}
}
void moveRobot() {
// 외부 클래스에서 멤버 내부 클래스를 사용할 수 있다.
Robot robot = new Robot();
robot.goTo = "right";
robot.moveTo();
}
Robot getRobot() {
return new Robot();
}
}
class MemberInnerClass {
public static void main(String[] args) {
// 외부 클래스의 인스턴스 생성
Factory fac = new Factory();
fac.moveRobot();
// 외부 클래스의 멤버 내부 클래스를 직접 인스턴스화.
Factory.Robot innerRobot = fac.new Robot();
// Factory.Robot innerRobot = new Factory().new Robot();
innerRobot.goTo = "up";
innerRobot.moveTo();
innerRobot.printMadeIn();
// 멤버 내부 클래스의 인스턴스를 반환하는 getter 메서드를 통해
// 멤버 내부 클래스의 인스턴스를 변수에 초기화함.
Factory.Robot returnedRobot = fac.getRobot();
returnedRobot.moveTo();
returnedRobot.printMadeIn();
}
}
로봇이 right 방향으로 움직입니다.
로봇이 up 방향으로 움직입니다.
해당 로봇은 로봇조아 회사에서 제작되었습니다.
로봇이 left 방향으로 움직입니다.
해당 로봇은 로봇조아 회사에서 제작되었습니다.
위 코드에서도 직접 사용하였는데, 외부 영역에서 외부 클래스의 멤버 내부 클래스를 직접 인스턴스화하여 변수에 할당하고자 한다면 다음의 형태로 생성하면 된다.
외부클래스.멤버내부클래스 변수명 = 외부클래스객체.new 내부클래스();
직접 실험해본 결과, new 앞에 외부 클래스명을 입력하면 에러가 발생한다. 그래서 외부 클래스의 객체를 참조하는 참조 변수명을 입력하여야 한다. 외부 클래스명이 아닌 외부 클래스로부터 생성된 객체를 넣는 것이므로 다음의 문법도 가능함을 확인하였다.
외부클래스명.멤버내부클래스명 변수명 = new 외부클래스생성자().new 내부클래스생성자();
위 예제에서 볼 수 있듯, 멤버 내부 클래스는 그 내부에 외부 클래스의 멤버 변수를 가져와 사용할 수 있다. 또한 반대로, 외부 클래스에서도 멤버 내부 클래스를 인스턴스화하여 사용할 수 있다.
지역 내부 클래스
지역 내부 클래스도 멤버 내부 클래스처럼 외부 클래스 내부에 선언된다. 그러나 차이점은 지역 내부 클래스는 외부 클래스 내의 메서드, if문, for문과 같은 중괄호 블록 안에 정의된다는 것이다. 그러면 중괄호 내부에서만 인스턴스를 사용할 수 있게 되고, 이는 마치 지역 변수와 마찬가지로 중괄호 외부에서는 사용할 수 없다는 점에서 지역 내부 클래스라는 이름이 붙은 것으로 보인다. 방금 언급했듯이, 외부 클래스 내의 중괄호 내부에서만 사용 가능하므로 외부로부터 클래스를 숨기고자 할 때 사용할 수 있다.
다음은 지역 내부 클래스를 사용하는 예제이다.
class Factory2 {
String factoryName = "로봇조아";
void showDemonstration() {
class Robot2 {
public String goTo = "left";
void moveTo() {
System.out.printf("로봇이 %s 방향으로 움직입니다.\n", goTo);
}
void printMadeIn() {
// 외부 클래스의 멤버 변수에 접근 가능.
System.out.printf("해당 로봇은 %s 회사에서 제작되었습니다.\n", factoryName);
}
}
System.out.println("로봇 시연회");
System.out.println("저희 로봇 제조 비법은 비밀이랍니다~!");
Robot2 rob = new Robot2();
rob.moveTo();
rob.printMadeIn();
}
}
class LocalInnerClass {
public static void main(String[] args) {
Factory2 fac = new Factory2();
fac.showDemonstration();
}
}
로봇 시연회
저희 로봇 제조 비법은 비밀이랍니다~!
로봇이 left 방향으로 움직입니다.
해당 로봇은 로봇조아 회사에서 제작되었습니다.
지역 내부 클래스도 외부 클래스의 멤버 변수를 사용할 수 있다.
익명 내부 클래스
앞서 살펴본 지역 내부 클래스는 외부 클래스 내부의 중괄호 블록 내부에서만 사용 가능하므로, 지역 내부 클래스에도 이름을 짓는 것이 부담일 수 있다. 이럴 때 클래스명을 생략한 “익명 내부 클래스”를 대신 사용할 수 있다.
익명 내부 클래스를 사용할 때에는 한 가지 주의할 점이 있다. 그것은 익명 내부 클래스로 사용하고자 하는 클래스가 반드시 어떤 부모 클래스로부터 상속을 받거나 또는 인터페이스를 구현받아야 한다. 클래스명 대신 부모 클래스 또는 인터페이스명을 대신 사용할 것이기 때문이다.
다음은 지역 내부 클래스를 익명 내부 클래스로 바꿔 사용하는 예제이다.
interface RobotBluePrint {
void moveTo();
void printMadeIn();
}
class Factory3 {
String factoryName = "로봇조아";
RobotBluePrint getRobot(String direction) {
/* // 지역 내부 클래스
class Robot3 implements RobotBluePrint {
String goTo;
Robot3(String goTo) {
this.goTo = goTo;
}
public void moveTo() {
System.out.printf("로봇이 %s 방향으로 움직입니다.\n", goTo);
}
public void printMadeIn() {
// 외부 클래스의 멤버 변수에 접근 가능.
System.out.printf("해당 로봇은 %s 회사에서 제작되었습니다.\n", factoryName);
}
}
return new Robot3(direction); */
// 익명 내부 클래스
return new RobotBluePrint() {
// 외부로부터 전달받을 인자가 있다면 이런 식으로 초기화하여 사용한다.
String goTo = direction;
public void moveTo() {
System.out.printf("로봇이 %s 방향으로 움직입니다.\n", goTo);
}
public void printMadeIn() {
// 외부 클래스의 멤버 변수에 접근 가능.
System.out.printf("해당 로봇은 %s 회사에서 제작되었습니다.\n", factoryName);
}
};
}
}
class InnerAnonymousClass {
public static void main(String[] args) {
Factory3 fac = new Factory3();
RobotBluePrint robot = fac.getRobot("Up");
robot.moveTo();
robot.printMadeIn();
}
}
로봇이 Up 방향으로 움직입니다.
해당 로봇은 로봇조아 회사에서 제작되었습니다.
위 예제에서 익명 내부 클래스를 사용한 부분만 떼어보겠다.
// 익명 내부 클래스
return new RobotBluePrint() {
// 외부로부터 전달받을 인자가 있다면 이런 식으로 초기화하여 사용한다.
String goTo = direction;
public void moveTo() {
System.out.printf("로봇이 %s 방향으로 움직입니다.\n", goTo);
}
public void printMadeIn() {
// 외부 클래스의 멤버 변수에 접근 가능.
System.out.printf("해당 로봇은 %s 회사에서 제작되었습니다.\n", factoryName);
}
}; // 세미콜론 필수.
클래스명 대신 상속받는 부모 클래스명 또는 인터페이스명을 사용한다. 위 예제에서는 RobotBluePrint라는 인터페이스를 대신 이름으로 사용하였다. 그리고 익명 내부 클래스는 실행문이므로 마지막에 반드시 세미콜론을 붙여야한다.
일반적인 클래스는 객체로 생성할 때 클래스 내 멤버 변수 초기화를 위해 생성자를 이용하여 인자로 값을 전달한다. 그런데 익명 내부 클래스는 생성자를 사용할 수 없다. 따라서 위 예제의 경우, 대신 외부 변수를 멤버 변수에 직접 초기화하는 방식으로 사용되었다.
안드로이드 프로그래밍에서, 이벤트 핸들러를 구현할 때 익명 내부 클래스 방식을 사용한다고 한다.
람다식 (lambda expression)
자바에서는 어떤 기능만 필요해서 하나의 메서드로만 구현을 하더라도 반드시 클래스의 내부에 정의해야 한다. 단순히 메서드만 사용할 것인데 클래스까지 정의해야한다니 어쩐지 낭비같다. 다행히도 자바 8 버전 이상부터는 함수형 프로그래밍 기법인 람다식을 지원한다.
기존의 익명 내부 클래스도 구현 받는 인터페이스가 존재해야 했듯, 람다식도 사용하기 위해선 인터페이스가 미리 정의되어 있어야 한다.
람다식의 문법은 대략 다음의 형태를 띈다.
(매개변수1, 매개변수2, ...) -> {실행할 코드};
다음은 람다식을 이용하는 예제이다.
interface RobotBluePrint2 {
void moveTo(String direction);
}
interface Adder {
int getAdd(int a, int b);
}
interface CombineStr {
String getCombinedStr(String a, String b);
}
interface Printer {
void printSomething();
}
class UseLambda {
public static void main(String[] args) {
RobotBluePrint2 robot;
// 람다식 사용 예. 화살표 왼쪽에는 매개변수들을,
// 화살표 오른쪽에는 실행할 코드를 작성.
robot = (String direction) -> {System.out.println(direction);};
robot.moveTo("Up");
// 매개변수가 하나일 경우 소괄호 생략 가능.
// 중괄호 내 코드가 한 줄일 경우 중괄호 생략 가능.
robot = direction -> System.out.println(direction);
robot.moveTo("Down");
Adder calc;
// 중괄호 블록 내부에 값을 반환하는 반환문 하나만 존재한다면
// return 키워드와 중괄호 모두 생략가능.
calc = (a, b) -> a + b;
System.out.println(calc.getAdd(3, 1));
CombineStr com;
// 중괄호 블록 내에 return 문이 있을 경우, 중괄호 생략 불가능.
com = (a, b) -> {return a + "-" + b;};
System.out.println(com.getCombinedStr("hi", "good"));
// 매개변수가 하나도 없다고 해서 소괄호를 생략해선 안된다.
Printer prin = () -> System.out.println("hi");
prin.printSomething();
}
}
Up
Down
4
hi-good
hi
람다식을 사용하기 위해선, 사용하고자 하는 인터페이스를 자료형으로 하는 변수를 하나 선언한 뒤, 그 변수에 람다식을 작성하면 된다. 그 후, 인터페이스 내부에 정의된 추상 메서드의 이름을 그대로 사용하여 호출하면 된다.
실험 결과, 인터페이스에 둘 이상의 추상 메서드를 정의하면 에러가 나는 것을 확인하였다. 이는 위 예제 코드를 보면 알겠지만, 인터페이스형 변수에 바로 람다식을 대입하는 방식이라, 만약 인터페이스 내에 정의된 추상 메서드가 2개 이상이면 람다식이 둘 중 어느 메서드를 구현하게 된 건지 모호해지기 때문에 에러를 발생시키는 것으로 추측된다.
그러면 코드가 방대해지면, 어떤 인터페이스가 람다식을 구현하기 위한 것인지 어떻게 구별할까? 이는 다음에 소개할 함수형 인터페이스를 이용하면 되겠다.
함수형 인터페이스
앞서 람다식을 사용하기 위해선 우선 인터페이스가 정의되어 있어야 했다. 그러나 인터페이스를 그냥 정의하면 이 인터페이스가 람다식을 위한 것인지 아닌지 인터페이스 정의부만 보면 헷갈릴 수 있다. 이를 대비하여, 자바에서는 함수형 인터페이스를 제공한다. 함수형 인터페이스는 람다식 전용 인터페이스이다. 앞서 람다식을 보면 알겠지만, 람다식은 매개변수와 중괄호 블록, 이렇게 둘 만으로 구분할 수 있기에 함수형 인터페이스 내부에는 단 하나의 추상 메서드만을 정의해야한다. 그리고, 해당 인터페이스가 람다식으로만 사용할 함수형 인터페이스임을 알리기 위해, 인터페이스 선언부 위에 @FunctionalInterface 어노테이션을 덧붙인다.
다음은 함수형 인터페이스와 함께 람다식을 사용하는 예제이다.
@FunctionalInterface
interface PrintBluePrint {
void printSomething(String s);
// void printAnother(String str); // 에러 발생.
}
class LambdaWithFuncInter {
public static void main(String[] args) {
PrintBluePrint printer = s -> {System.out.println(s);};
printer.printSomething("hello, world!");
}
}
hello, world!
References
[1] 이재환, “이재환의 자바 프로그래밍 입문”, (골든래빗, 2021)
This content is licensed under
CC BY-NC 4.0
댓글남기기