static 메서드와 정적 import
static 메서드가 인스턴스 변수를 바로 쓸 수 없는 이유를 이해하고, 객체 없이 쓰는 유틸리티 클래스와 정적 import를 언제 쓰면 좋은지 배웁니다.
앞 글에서는 회원 생성 횟수를 Member.createdCount()로 읽었습니다. 객체를 만들지 않고 클래스 이름으로 바로 호출한 것입니다. 사실 이런 메서드는 이미 여러 번 써 왔습니다. 두 수 가운데 큰 값을 구하는 Math.max(3, 7)도 Math 객체를 만들지 않고 호출합니다. 반면 member.describe()는 반드시 어떤 회원 객체를 통해 호출해야 합니다. 이 차이를 만드는 것이 메서드 선언에 붙은 static입니다.
static이 붙은 메서드는 클래스에 속하므로 객체 없이 호출할 수 있습니다. 대신 그 때문에 할 수 없는 일도 생깁니다. 이 글에서는 static 메서드가 인스턴스 변수를 바로 쓸 수 없는 이유를 먼저 살펴봅니다. 이어서 어떤 기능을 static 메서드로 만들면 좋은지, 클래스 이름을 생략하게 해 주는 정적 import는 언제 쓰는지를 차례로 다룹니다.
static 메서드에서 인스턴스 변수를 쓸 수 없는 이유
다음 코드는 static 메서드인 main에서 인스턴스 변수 size를 바로 읽으려고 합니다. 컴파일하면 오류가 납니다.
public final class StaticInstanceAccess {
private int size;
public static void main(String[] args) {
System.out.println(size);
}
}lab/StaticInstanceAccess.java:5: error: non-static variable size cannot be referenced from a static context
System.out.println(size);
^
1 error오류 메시지는 'static이 아닌 변수 size를 static 문맥(static context)에서 참조할 수 없다'는 뜻입니다. static 문맥이란 static 메서드의 본문처럼 특정 객체 없이 실행되는 코드를 말합니다. 그런데 size는 인스턴스 변수라서 StaticInstanceAccess 객체마다 하나씩 있습니다. main이 실행될 때 객체가 하나도 없을 수도 있고 여러 개 있을 수도 있으니, 컴파일러로서는 어느 객체의 size를 읽으라는 것인지 알 수 없습니다.
인스턴스 메서드에서는 이런 문제가 생기지 않습니다. member.describe()처럼 호출하면 그 호출에는 member가 가리키는 객체가 함께 전달되고, 메서드 안에서는 그 객체를 this로 가리킵니다. 인스턴스 메서드 안에서 age라고만 써도 되는 것은 컴파일러가 이를 this.age로 읽기 때문입니다. 반면 static 메서드는 클래스 이름으로 호출되므로 함께 전달되는 객체가 없고, 따라서 this도 없습니다. 실제로 위 main에서 this.size라고 쓰면 non-static variable this cannot be referenced from a static context라는 오류가 납니다. this라는 이름 자체를 쓸 수 없다는 뜻입니다.
같은 이유로 static 메서드에서는 인스턴스 메서드도 객체 없이 호출할 수 없습니다. 이때도 non-static method register() cannot be referenced from a static context처럼 메서드 이름만 다른 오류가 납니다. 해결 방법은 어느 객체인지 직접 정해 주는 것입니다. 객체를 만들고 그 참조를 통해 접근하면 컴파일됩니다.
StaticInstanceAccess instance = new StaticInstanceAccess();
System.out.println(instance.size);이제는 instance가 가리키는 객체의 size라는 것이 분명하므로, 따로 값을 넣지 않은 int 필드의 기본값 0이 출력됩니다. 즉 static 메서드가 인스턴스 멤버를 절대 쓸 수 없다는 뜻은 아닙니다. 매개변수나 지역 변수로 객체 참조를 얻으면, 그 참조를 통해 객체의 필드와 메서드를 얼마든지 쓸 수 있습니다. static 메서드에 없는 것은 따로 적지 않아도 정해지는 객체, 즉 this뿐입니다. 지금까지 main에서 객체를 만들고 그 객체의 메서드를 호출해 온 것도 바로 이 방식이었습니다.
인스턴스 메서드와 static 메서드가 쓸 수 있는 것
두 종류의 메서드가 각각 무엇에 접근할 수 있는지 한 프로그램에서 비교해 보겠습니다. StaticCallRules는 객체마다 따로 세는 인스턴스 변수 localCount와, 모든 객체가 함께 세는 static 변수 globalCount를 가집니다.
public final class StaticCallRules {
private static int globalCount;
private int localCount;
public static void main(String[] args) {
StaticCallRules first = new StaticCallRules();
StaticCallRules second = new StaticCallRules();
first.register();
second.register();
second.register();
System.out.println(first.localSummary());
System.out.println(second.localSummary());
System.out.println(globalSummary());
}
private void register() {
localCount++;
globalCount++;
}
private String localSummary() {
return "local=" + localCount + ", global=" + globalCount;
}
private static String globalSummary() {
return "all=" + globalCount;
}
}local=1, global=3
local=2, global=3
all=3register는 인스턴스 메서드이므로 this가 있습니다. first.register()로 호출하면 this는 first가 가리키는 객체이고, register는 그 객체의 localCount와 클래스의 globalCount를 하나씩 올립니다. first에 한 번, second에 두 번 호출했으므로 first의 localCount는 1, second의 localCount는 2입니다. 세 번의 호출이 모두 올린 globalCount는 3입니다. localSummary도 인스턴스 메서드라서 자기 객체의 localCount와 함께 쓰는 globalCount를 모두 읽고, 그래서 출력의 첫 두 줄은 객체마다 다릅니다.
globalSummary는 static 메서드이므로 this가 없고, static 변수인 globalCount만 바로 읽을 수 있습니다. 어느 객체를 기준으로 하든 결과가 같아야 하는 값이므로 static 메서드가 잘 맞습니다. main에서 globalSummary()를 클래스 이름 없이 호출한 것은 같은 클래스 안이기 때문이고, 다른 클래스에서라면 StaticCallRules.globalSummary()처럼 클래스 이름을 붙여야 합니다. 그리고 static인 main이 first.register()를 호출할 수 있었던 것은 first라는 객체 참조를 통해 호출했기 때문입니다.
StaticCallRules에서 register를 first에1번 second에2번 호출한 뒤 객체별 localCount는1과2이고 클래스 공유 globalCount는3이다. 인스턴스 요약은 자기 객체와 공유 상태를 읽고 정적 요약은 공유 상태만 읽는다.
인스턴스 요약은 자기 상태와 공유 상태를 함께 읽는다
정적 요약은 this 없이 공유 개수 3을 읽어 all=3을 반환합니다.
StaticCallRules에서 first.register()는 한 번, second.register()는 두 번 호출한 뒤의 상태입니다.
| 호출 | 수신 객체와 읽는 값 | 출력 |
|---|---|---|
first.localSummary() | this는 first가 가리키는 객체입니다.
| local=1, global=3 |
second.localSummary() | this는 second가 가리키는 객체입니다.
| local=2, global=3 |
globalSummary() | static 호출이므로 수신 객체와 this가 없습니다.공유 | all=3 |
first.localSummary()this는first가 가리키는 객체입니다. 그 객체의localCount = 1과 공유globalCount = 3을 읽어local=1, global=3을 출력합니다.second.localSummary()this는second가 가리키는 객체입니다. 그 객체의localCount = 2와 공유globalCount = 3을 읽어local=2, global=3을 출력합니다.globalSummary()static호출이므로 수신 객체와this가 없습니다. 공유globalCount = 3만 읽어all=3을 출력합니다.
각 register()는 호출된 객체의 localCount와 클래스 공유 globalCount를 하나씩 늘립니다. static 메서드도 명시적인 객체 참조가 있으면 그 객체의 인스턴스 멤버에 접근할 수 있습니다.
| 어디서 쓰나 | 인스턴스 변수, 인스턴스 메서드 | static 변수, static 메서드 |
|---|---|---|
| 인스턴스 메서드 | 바로 쓸 수 있음(this 객체의 것) | 바로 쓸 수 있음 |
static 메서드 | 객체 참조를 통해서만 쓸 수 있음 | 바로 쓸 수 있음 |
표에서 보듯 인스턴스 메서드는 static 변수도 자유롭게 쓸 수 있습니다. 하지만 할 수 있다고 해서 늘 좋은 것은 아닙니다. 인스턴스 메서드가 static 변수를 바꾸면 한 객체의 동작이 다른 모든 객체에 영향을 줍니다. 앞 글의 회원 번호 예제에서 본 것처럼, 그 공유가 정말 필요한지 먼저 따져 봐야 합니다.
static 변수와 마찬가지로 static 메서드도 first.globalSummary()처럼 객체 참조를 통해 호출할 수는 있습니다. 하지만 그렇게 쓰면 결과가 first에 따라 달라지는 것처럼 보여 읽는 사람이 오해하기 쉽습니다. static 메서드는 클래스 이름으로 호출하는 것이 좋습니다.
객체 없이 쓰는 유틸리티 클래스
그렇다면 어떤 메서드를 static으로 만들면 좋을까요? 가장 대표적인 것은 결과가 인수에만 달려 있는 메서드입니다. Math.max(3, 7)은 언제 호출하든 7을 돌려주고, 이 계산에는 어떤 객체의 상태도 필요하지 않습니다. 객체의 상태를 쓰지 않으니 객체를 만들 이유도 없습니다. 이런 메서드만 모아 둔 클래스를 유틸리티 클래스(utility class)라고 부릅니다. 다음은 회원 나이 배열의 합계, 평균, 최댓값을 구하는 유틸리티 클래스입니다.
public final class MemberStatisticsUtility {
public static void main(String[] args) {
int[] age = {40, 50, 30};
System.out.println("sum=" + MemberStatistics.sum(age));
System.out.println("average=" + MemberStatistics.average(age));
System.out.println("max=" + MemberStatistics.max(age));
}
private static final class MemberStatistics {
private MemberStatistics() {
throw new AssertionError("no instances");
}
static int sum(int[] values) {
int total = 0;
for (int value : values) {
total += value;
}
return total;
}
static double average(int[] values) {
if (values.length == 0) return 0.0;
return (double) sum(values) / values.length;
}
static int max(int[] values) {
if (values.length == 0) throw new IllegalArgumentException("empty");
int result = values[0];
for (int index = 1; index < values.length; index++) {
if (values[index] > result) result = values[index];
}
return result;
}
}
}sum=120
average=40.0
max=50세 메서드는 모두 매개변수 values만 읽어서 결과를 만들고, MemberStatistics에는 필드가 하나도 없습니다. average가 sum을 클래스 이름 없이 호출한 것처럼, 같은 클래스 안에서는 static 메서드를 이름만으로 호출할 수 있습니다. 평균은 합계 120을 개수 3으로 나눈 값입니다. 합계를 (double)로 바꾼 뒤 나누었기 때문에 정수 나눗셈이 아니라 실수 나눗셈이 되어 40.0이 출력됩니다.
생성자를 private으로 선언한 것도 유틸리티 클래스의 특징입니다. 객체를 만들어도 쓸 데가 없으니 아예 만들 수 없게 막는 것입니다. JDK의 Math, Arrays, Collections도 같은 방식이라서, new Math()라고 쓰면 Math() has private access in Math라는 컴파일 오류가 납니다. 생성자 안의 throw new AssertionError("no instances")는 한 겹 더 막는 장치입니다. private 생성자도 같은 클래스 안에서는 호출할 수 있습니다. 이 예제처럼 중첩 클래스로 넣었다면 접근 제어자와 정보 은닉에서 본 대로 바깥 클래스의 main에서도 호출할 수 있습니다. 누군가 실수로 객체를 만들더라도 실행하자마자 오류로 드러나게 하려는 것입니다.
유틸리티 메서드라고 해서 입력을 신경 쓰지 않아도 되는 것은 아닙니다. 위 코드에 빈 배열을 넘기면 sum은 0, average는 0.0을 돌려주지만, max는 가장 큰 원소를 정할 수 없으므로 IllegalArgumentException을 던집니다. 세 메서드가 빈 입력을 서로 다르게 처리하는 셈이니, 실제 코드라면 이런 규칙을 주석이나 문서로 알려 주어야 합니다. 또 null 배열은 따로 검사하지 않으므로 반복문이 배열을 읽는 순간 NullPointerException이 나고, 합계가 int 범위를 넘으면 엉뚱한 값이 나옵니다. 예를 들어 {Integer.MAX_VALUE, 1}의 합은 -2147483648이 됩니다. 메서드에 static을 붙인다고 이런 입력이 저절로 걸러지지는 않습니다.
static 메서드가 인수로 받은 배열을 바꾸지 않는다는 보장도 없습니다. 이 세 메서드는 배열을 읽기만 하도록 작성했기 때문에 호출한 쪽의 배열이 그대로 남는 것입니다. static 메서드에서도 values[0] = 0;처럼 인수로 받은 배열의 칸을 얼마든지 바꿀 수 있습니다.
상태가 있는 기능까지 static으로 만들면
유틸리티 클래스가 편하다고 모든 기능을 static 메서드로 만들면 곤란합니다. 회원 저장소의 add를 예로 들어 보겠습니다. 저장소 객체가 members 배열과 size를 필드로 가지고 있으면, 호출하는 쪽은 추가할 회원만 넘기면 됩니다. 하지만 add를 static으로 바꾸면 메서드에 this가 없으므로 배열과 개수를 매번 인수로 넘겨야 하고, 바뀐 개수도 돌려받아 직접 보관해야 합니다.
// 저장소 객체가 배열과 개수를 가지고 있을 때
registry.add(member);
// static 메서드에 배열과 개수를 매번 넘길 때
size = MemberRegistry.add(members, size, member);두 번째 방식에서는 배열과 개수를 서로 맞게 유지하는 일이 다시 호출하는 쪽으로 넘어갑니다. 누군가 size를 갱신하지 않거나 다른 배열을 넘기면 저장소의 상태가 쉽게 어긋납니다. 캡슐화로 회원 저장소 완성하기에서 데이터와 그 데이터를 다루는 동작을 객체 안에 함께 숨겨서 막았던 문제가 되살아나는 셈입니다. 그래서 객체의 상태를 읽거나 바꾸는 기능은 인스턴스 메서드로 두고, 상태 없이 인수만으로 끝나는 계산만 static 메서드로 뺍니다.
정적 import로 클래스 이름 생략하기
static 메서드를 쓸 때마다 Math.min, Math.max처럼 클래스 이름을 붙이는 것이 번거로울 때가 있습니다. 정적 import(static import)를 쓰면 다른 클래스의 static 멤버를 이름만으로 쓸 수 있습니다. 다음 프로그램은 나이를 14부터 120 사이로 맞추는 계산에 정적 import를 사용합니다.
package members.app;
import static java.lang.Math.max;
import static java.lang.Math.min;
public final class StaticImportDemo {
public static void main(String[] args) {
int age = 130;
int normalized = max(14, min(age, 120));
System.out.println("normalized=" + normalized);
}
}이 클래스는 members.app 패키지에 속하므로, 패키지와 import에서 했던 것처럼 프로젝트 폴더에서 다음 명령으로 컴파일하고 실행합니다. 실행할 때는 패키지 이름까지 붙인 members.app.StaticImportDemo를 적습니다. age가 130이므로 출력에는 상한 120으로 맞춘 값이 나와야 합니다.
javac -d out src/members/app/StaticImportDemo.java
java -cp out members.app.StaticImportDemonormalized=120import static java.lang.Math.max;는 'java.lang.Math 클래스의 static 멤버 max를 이 파일에서 이름만으로 쓰겠다'는 선언입니다. 일반 import가 다른 패키지의 클래스 이름을 가져온다면, 정적 import는 클래스 안에 있는 static 메서드나 static 변수의 이름을 가져옵니다. 그래서 본문에 max와 min만 써도 컴파일러가 Math.max와 Math.min을 찾아 줍니다. Math는 java.lang 패키지에 있어서 클래스 이름을 쓸 때는 import가 필요 없지만, 클래스 이름 없이 멤버를 쓰려면 이렇게 정적 import를 적어야 합니다. 정적 import는 컴파일러가 이름을 찾는 방법만 바꿀 뿐이므로, 컴파일된 결과는 Math.max(14, Math.min(age, 120))이라고 쓴 것과 같습니다.
계산은 안쪽부터 진행됩니다. 먼저 min(130, 120)이 120을 돌려주고, 이어서 max(14, 120)이 120을 돌려줍니다. 입력이 10이라면 min(10, 120)이 10, max(14, 10)이 14가 됩니다. 즉 이 식은 범위를 벗어난 값을 가까운 끝 값인 14나 120으로 맞춥니다. Java 21부터는 같은 계산을 하는 Math.clamp(age, 14, 120)도 쓸 수 있습니다.
이렇게 값을 맞추는 방식은 연습 문제의 MemberAgeRules처럼 범위를 벗어난 입력을 예외로 거절하는 방식과 결과가 다릅니다. 나이 130을 120으로 고쳐 받아들일지, 잘못된 입력으로 보고 거절할지는 요구 사항에 따라 따로 정해야 합니다. 정적 import는 표기만 줄일 뿐 이런 처리 방식에는 아무 영향이 없습니다.
정적 import는 언제 쓸까
정적 import는 코드를 짧게 해 주지만, 그 대가로 메서드가 어느 클래스에 속하는지가 코드에서 사라집니다. min만 보고는 그것이 Math.min인지, 같은 클래스에 있는 메서드인지, 다른 클래스에서 가져온 것인지 바로 알 수 없습니다. 그래서 정적 import는 주인이 누구인지 모두가 아는 경우에 제한적으로 씁니다. 수학 계산식처럼 같은 클래스의 메서드가 반복해서 나오는 곳이나, JUnit 테스트의 assertEquals처럼 정적 import로 쓰는 관례가 굳어진 경우가 대표적입니다. 반대로 SignupPolicy.normalizeAge(age) 같은 프로젝트 고유의 규칙은 클래스 이름을 남겨 두어야, 읽는 사람이 그 규칙이 어디에 정의되어 있는지 알 수 있습니다.
클래스의 모든 static 멤버를 한꺼번에 가져오는 별표(*) 정적 import도 있습니다.
import static java.lang.Math.*;편해 보이지만 이름이 충돌하기 쉽습니다. 예를 들어 Integer와 Math에는 둘 다 max(int, int)가 있으므로, 두 클래스를 모두 별표로 가져온 파일에서 max를 호출하면 컴파일러가 어느 쪽인지 정하지 못합니다.
import static java.lang.Integer.*;
import static java.lang.Math.*;
public final class AmbiguousStaticImport {
public static void main(String[] args) {
System.out.println(max(14, 120));
}
}lab/AmbiguousStaticImport.java:6: error: reference to max is ambiguous
System.out.println(max(14, 120));
^
both method max(int,int) in Integer and method max(int,int) in Math match
1 error오류 메시지 아래쪽의 both method ... match 줄이 충돌한 두 후보를 알려 줍니다. 이 파일에서 import static java.lang.Math.*;를 import static java.lang.Math.max;로 바꾸면 컴파일되고 120이 출력됩니다. 하나씩 가져온 이름이 별표로 가져온 이름보다 우선하기 때문입니다.
게다가 별표 import를 쓰면, 가져온 클래스에 나중에 static 멤버가 추가되기만 해도 이런 충돌이 새로 생길 수 있습니다. 필요한 멤버만 하나씩 가져오면 이 파일이 무엇을 쓰는지 한눈에 보이고 충돌할 일도 줄어듭니다.
main이 static인 이유와 main이 할 일
마지막으로 지금까지 매번 써 온 public static void main(String[] args)를 다시 보겠습니다. 프로그램이 시작되는 순간에는 아직 아무 객체도 없습니다. 그래서 JVM이 객체 없이 클래스 이름만으로 호출할 수 있도록 main을 static으로 선언하는 것이 자바의 오랜 규칙이었습니다. Java 25부터는 void main()처럼 static이 아닌 인스턴스 main 메서드도 쓸 수 있는데, 이때는 실행기가 먼저 그 클래스의 객체를 만든 뒤 main을 호출합니다(JEP 512). 이 장의 예제는 모두 전통적인 static 형식의 main을 씁니다.
static인 main에서 모든 일을 처리하려고 하면, 앞에서 본 대로 this가 없으니 필요한 값을 모두 static 변수에 두게 됩니다. 그러면 프로그램이 static 변수와 static 메서드의 모음이 되어, 5장의 첫 글에서 본 절차 지향 코드로 돌아갑니다. 데이터와 그 데이터를 다루는 규칙이 다시 흩어지는 것입니다. 그래서 main은 필요한 객체를 만들어 서로 연결하고, 실제 일은 객체의 인스턴스 메서드에 넘기는 조립 역할만 하는 것이 좋습니다.
public final class StaticMainComposition {
public static void main(String[] args) {
SignupApplication application = new SignupApplication(new MemberRegistry(2));
application.run();
}
private static final class SignupApplication {
private final MemberRegistry registry;
SignupApplication(MemberRegistry registry) {
this.registry = registry;
}
void run() {
registry.add(40);
registry.add(50);
System.out.println("total=" + registry.total());
}
}
private static final class MemberRegistry {
private final int[] age;
private int size;
MemberRegistry(int capacity) {
age = new int[capacity];
}
void add(int value) {
age[size++] = value;
}
int total() {
return MemberCalculations.sum(age, size);
}
}
private static final class MemberCalculations {
static int sum(int[] values, int size) {
int total = 0;
for (int index = 0; index < size; index++) {
total += values[index];
}
return total;
}
}
}total=90main은 용량 2인 MemberRegistry를 만들어 SignupApplication의 생성자에 넘기고, run()을 호출한 뒤 끝납니다. 회원 나이를 담은 배열과 개수는 MemberRegistry 객체가 필드로 가지고 있고, 어떤 순서로 회원을 추가할지는 SignupApplication의 인스턴스 메서드 run이 정합니다. static 메서드로 남은 것은 상태 없이 배열과 개수만으로 합계를 구하는 MemberCalculations.sum뿐입니다. sum이 배열과 함께 size를 받는 이유는 배열의 길이와 실제로 채운 칸의 수가 다를 수 있어서, 채운 칸까지만 더해야 하기 때문입니다.
StaticMainComposition의 정적 main이 애플리케이션과 용량2 저장소를 조립한다. 두 인스턴스의 참조 관계와 int 배열40,50 및 size2를 읽는 정적 sum 호출을 구분한다.
정적 main이 객체를 조립하고 인스턴스가 상태를 맡는다
저장소의 배열과 size를 읽는 합계 계산만 정적 메서드로 분리합니다.
StaticMainComposition의 협력 관계입니다. 저장소 값은 run()의 두 add를 마치고 합계를 구할 때를 표시합니다.
- 정적
main의 객체 조립new MemberRegistry(2)로 저장소를 만들고, 그 참조로SignupApplication객체를 생성한 뒤application.run()을 호출합니다. SignupApplication의 저장소 참조final MemberRegistry registry필드에 전달받은 참조를 보관합니다. 인스턴스 메서드run()이 그 저장소의add(40),add(50),total()을 차례로 호출합니다.MemberRegistry가 보관한 상태두 추가 뒤
age는{40, 50}인 길이2의int[]를 가리키며size = 2입니다.total()은MemberCalculations.sum(age, size)를 호출합니다.MemberCalculations의 정적 계산클래스의
static sum(values, size)가 인덱스0, 1의 값을 읽어90을 반환합니다. 이 호출은 배열 값을 바꾸지 않습니다.
합계 90이 total()을 거쳐 run()으로 돌아가 total=90을 출력합니다. 이 저장소의 add에는 용량·나이 검증이 없으며, 위 상태는 원문 두 번의 추가에 한정합니다.
이 예제의 add는 구조를 보여 주려고 용량 검사와 나이 검사를 생략했습니다. 그래서 세 번째 나이를 추가하면 배열 범위를 벗어나 ArrayIndexOutOfBoundsException이 납니다.
정리
static메서드는 객체 없이 클래스 이름으로 호출하므로this가 없습니다. 그래서 인스턴스 변수와 인스턴스 메서드는 객체 참조를 통해서만 쓸 수 있습니다.- 인스턴스 메서드는 자기 객체의 인스턴스 멤버와
static멤버를 모두 바로 쓸 수 있습니다. - 결과가 인수에만 달린 계산은
static메서드로 만들기 좋고, 이런 메서드만 모은 유틸리티 클래스는private생성자로 객체 생성을 막습니다. 객체의 상태를 다루는 기능은 인스턴스 메서드로 둡니다. - 정적 import는
static멤버를 클래스 이름 없이 쓰게 해 줍니다. 주인이 분명한 곳에서만 쓰고, 별표보다 필요한 멤버를 하나씩 가져옵니다. main은 객체를 만들어 연결하는 조립 역할을 하고, 실제 일은 객체의 인스턴스 메서드에 넘깁니다.
연습 문제
- 다음
Visitor클래스는 컴파일되지 않습니다. 어느 줄에서 왜 오류가 나는지 설명하고, 고치는 방법을 두 가지 제시하세요.
public final class Visitor {
private static int totalVisits;
private int visits;
void visit() {
visits++;
totalVisits++;
}
static String report() {
return "total=" + totalVisits + ", mine=" + visits;
}
}- 인스턴스를 만들 수 없는
MemberAgeRules유틸리티 클래스를 만들고,isAdult(int age)와group(int age)를static메서드로 제공하세요. 14세 미만과 120세 초과는IllegalArgumentException으로 거절하고,isAdult는 18세 이상이면true를 돌려줍니다.group은 65세 이상을senior, 18세 이상을adult, 나머지를teen으로 분류합니다. 13, 14, 17, 18, 64, 65, 120, 121을 넣어 확인해 보세요. - 다음 메서드를 각각
static메서드와 인스턴스 메서드 가운데 무엇으로 만들어야 할지 이유와 함께 답하세요.Member의describe(): 회원의 이메일과 나이를 문자열로 만듭니다.normalizeEmail(String email): 이메일의 앞뒤 공백을 없애고 소문자로 바꿉니다.MemberRegistry의add(Member member): 저장소에 회원을 추가합니다.Member의createdCount(): 지금까지 만든 회원 수를 돌려줍니다.
해설 보기
1번의 오류는 report의 return 줄에서 납니다. report는 static 메서드라서 this가 없는데, visits는 Visitor 객체마다 있는 인스턴스 변수이므로 어느 객체의 visits인지 정할 수 없습니다. 그래서 javac는 non-static variable visits cannot be referenced from a static context라는 오류를 냅니다. 같은 줄의 totalVisits는 static 변수이므로 문제가 없습니다.
고치는 방법은 두 가지입니다. 객체마다 다른 값을 보여 주는 것이 목적이라면 static을 빼서 인스턴스 메서드로 만듭니다. 클래스 단위의 메서드로 남겨야 한다면 어느 객체인지 매개변수로 받습니다.
// 방법 1: 인스턴스 메서드로 바꾼다. 호출은 visitor.report()
String report() {
return "total=" + totalVisits + ", mine=" + visits;
}
// 방법 2: 어느 객체인지 매개변수로 받는다. 호출은 Visitor.report(visitor)
static String report(Visitor visitor) {
return "total=" + totalVisits + ", mine=" + visitor.visits;
}Visitor 객체 a에 두 번, b에 한 번 visit()을 호출했다면 방법 1의 a.report()는 total=3, mine=2를, 방법 2의 Visitor.report(b)는 total=3, mine=1을 돌려줍니다. 두 방법 모두 동작하지만, 이 메서드처럼 객체 하나의 정보를 보여 주는 동작에는 방법 1이 더 자연스럽습니다.
2번은 다음과 같이 작성할 수 있습니다.
public final class MemberAgeRulesUtility {
public static void main(String[] args) {
int[] ages = {13, 14, 17, 18, 64, 65, 120, 121};
for (int age : ages) {
try {
System.out.println(age + ": " + MemberAgeRules.isAdult(age) + ", " + MemberAgeRules.group(age));
} catch (IllegalArgumentException e) {
System.out.println(age + ": rejected");
}
}
}
private static final class MemberAgeRules {
private MemberAgeRules() {
throw new AssertionError("no instances");
}
static boolean isAdult(int age) {
validate(age);
return age >= 18;
}
static String group(int age) {
validate(age);
if (age >= 65) return "senior";
return age >= 18 ? "adult" : "teen";
}
private static void validate(int age) {
if (age < 14 || age > 120) throw new IllegalArgumentException("age");
}
}
}13: rejected
14: false, teen
17: false, teen
18: true, adult
64: true, adult
65: true, senior
120: true, senior
121: rejected두 메서드는 인수 age만으로 결과를 정하고 저장해 둘 상태가 없으므로 static이 잘 맞습니다. 범위 검사는 validate 하나에 모아 두 메서드가 함께 쓰고, 클래스 밖에서 쓸 일이 없으므로 private으로 감췄습니다. 13과 121은 isAdult 안의 validate에서 예외가 나므로 catch로 넘어가 rejected가 출력됩니다. group이 65 이상인지를 먼저 검사하는 데도 이유가 있습니다. 18 이상인지를 먼저 검사해 adult를 반환하면 65세도 그 자리에서 adult로 끝나, senior를 반환하는 줄에 도달하지 못합니다.
3번에서 describe()는 회원마다 다른 email과 age를 읽으므로 인스턴스 메서드입니다. normalizeEmail은 결과가 인수로 받은 문자열에만 달려 있으므로 static 메서드가 자연스럽습니다. add는 저장소의 배열과 개수를 바꾸므로 인스턴스 메서드여야 하며, static으로 만들면 본문에서 본 것처럼 상태를 매번 넘겨야 합니다. createdCount()는 클래스 전체에 하나인 static 변수를 읽으므로 static 메서드가 맞습니다.
다음 글
다음 글에서는 변수에 값을 한 번만 대입하게 하는 final을 다룹니다. 이 글에서는 나이의 최솟값 14와 최댓값 120을 코드에 숫자 그대로 적었는데, 이런 값에 이름을 붙여 static final 상수로 만드는 방법도 함께 배웁니다.