메서드 오버로딩과 자동 형변환
이름이 같은 메서드를 매개변수 목록으로 구분하는 오버로딩을 익히고, 컴파일러가 인수의 타입과 자동 형변환을 보고 호출할 메서드를 고르는 규칙을 배웁니다.
지금까지 System.out.println에 문자열도, 정수도, 실수도 넘겼습니다. println("hello"), println(40), println(45.0)이 모두 같은 이름으로 동작하는데, 사실 System.out의 타입인 PrintStream 클래스 안에는 println(String), println(int), println(double)처럼 매개변수 타입만 다른 메서드가 따로따로 선언되어 있습니다. 자바는 호출문에 적힌 인수를 보고 그중 하나를 골라 실행합니다. 이처럼 한 클래스 안에 이름은 같고 매개변수 목록이 다른 메서드를 여러 개 두는 것을 오버로딩(overloading)이라고 합니다.
오버로딩 덕분에 호출하는 쪽은 printInt, printString 같은 이름을 따로 외우지 않아도 됩니다. 대신 컴파일러가 어느 메서드를 고르는지 알아야 합니다. 인수 타입이 매개변수와 정확히 같지 않으면 자바는 값을 자동으로 넓혀서 받을 수 있는 메서드를 찾고, 그런 메서드가 여럿이면 규칙에 따라 하나를 고릅니다. 이 규칙을 모르면 생각과 다른 메서드가 실행되거나, 컴파일러가 하나를 고르지 못해 호출 자체가 컴파일 오류가 됩니다. 이 글에서는 먼저 인수가 매개변수 타입으로 자동 변환되는 규칙을 보고, 이어서 컴파일러가 같은 이름의 메서드 가운데 하나를 고르는 과정을 따라갑니다.
인수는 매개변수 타입으로 자동으로 넓혀진다
메서드와 매개변수에서 본 것처럼 메서드를 호출하면 인수의 값이 매개변수에 복사됩니다. 그런데 인수와 매개변수의 타입이 꼭 같아야 하는 것은 아닙니다. 다음 예제는 int 인수를 long 매개변수에 넘깁니다.
public final class WideningCall {
public static void main(String[] args) {
int age = 40;
long total = addBonus(age, 5);
System.out.println(total);
}
private static long addBonus(long age, long bonus) {
return age + bonus;
}
}인수와 매개변수의 타입이 다른데도 컴파일 오류는 나지 않습니다. 실행하면 addBonus가 돌려준 long 값이 그대로 출력됩니다.
45addBonus는 long 두 개를 받도록 선언되어 있지만, 호출문의 인수 age와 5는 둘 다 int입니다. int로 나타낼 수 있는 값은 모두 long으로도 나타낼 수 있으므로, 자바는 값을 복사하면서 40을 40L로, 5를 5L로 바꿔 전달합니다. 메서드 안에서는 long끼리 더해 45가 되고, 그 값이 total에 담겨 출력됩니다. 이렇게 더 넓은 범위의 타입으로 바꾸는 자동 형변환을 확대 변환(widening conversion)이라고 합니다. 변수 스코프와 숫자 형변환에서 long longAge = age;처럼 대입할 때 일어나던 변환이 메서드 호출에서도 똑같이 일어나는 것입니다.
반대 방향은 자동으로 일어나지 않습니다. 이번에는 long 값을 int 매개변수에 넘겨 보겠습니다.
public final class NarrowingCall {
public static void main(String[] args) {
long raw = 3_000_000_000L;
int value = normalize(raw);
System.out.println(value);
}
private static int normalize(int age) {
return Math.max(age, 0);
}
}lab/NarrowingCall.java:4: error: incompatible types: possible lossy conversion from long to int
int value = normalize(raw);
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error메시지의 possible lossy conversion은 '값을 잃을 수 있는 변환'이라는 뜻입니다. int에는 2,147,483,647(약 21억)까지만 담을 수 있는데, raw의 30억은 그 범위를 벗어납니다. 다만 컴파일러는 실제 값이 범위 안에 있는지까지 따지지 않습니다. long이라는 타입만 보고, 손실이 생길 수 있으면 호출을 막습니다.
normalize((int) raw)처럼 명시적으로 캐스트하면 컴파일은 됩니다. 하지만 범위를 벗어난 값은 잘려서 전혀 다른 숫자가 됩니다. 실제로 (int) raw는 -1294967296이 되고, normalize는 이 음수를 0으로 바꿔 돌려줍니다. 30억이 오류 하나 없이 조용히 0이 된 것입니다. 그래서 호출을 통과시키려고 캐스트부터 붙이면 안 됩니다. 이 값이 실제로 어느 범위까지 올 수 있는지, 매개변수 타입을 long으로 바꾸는 편이 맞는지를 먼저 따져야 합니다.
어떤 타입이 어떤 타입으로 자동으로 넓혀지는지는 다음 표와 같습니다.
| 인수 타입 | 자동으로 넘길 수 있는 매개변수 타입 |
|---|---|
byte | short, int, long, float, double |
short | int, long, float, double |
char | int, long, float, double |
int | long, float, double |
long | float, double |
float | double |
큰 흐름은 byte → short → int → long → float → double이고, char는 문자 코드 값이 int로 넓혀지는 방식으로 이 흐름에 합류합니다. 예를 들어 'A'를 long 매개변수에 넘기면 65가 전달됩니다. boolean은 어떤 숫자 타입과도 서로 변환되지 않습니다.
한 가지 주의할 점은 자동으로 변환된다고 해서 값이 그대로 보존되는 것은 아니라는 점입니다. int나 long을 float로, long을 double로 넓히면 범위는 충분하지만 정밀도가 모자라서, 아주 큰 정수의 끝자리가 바뀔 수 있습니다. 이 현상은 변수 스코프와 숫자 형변환에서 직접 확인했습니다. 컴파일이 된다는 것과 정보가 완전히 보존된다는 것은 서로 다른 이야기입니다.
더 깊이: 대입은 되는데 호출은 안 되는 상수
byte 매개변수를 가진 메서드에 정수 상수를 넘기면 의외의 오류를 만납니다. 같은 상수를 byte 변수에 대입할 때는 아무 문제가 없기 때문에 더 헷갈립니다.
byte level = 10; // 컴파일된다
setLevel(10); // setLevel(byte level)이라면 컴파일 오류byte level = 10;이 컴파일되는 것은 대입에 적용되는 특별 규칙 덕분입니다. 오른쪽이 컴파일할 때 값이 정해지는 int 상수이고 그 값이 byte 범위 안에 있으면, 대입할 때는 자동으로 좁혀 줍니다. 메서드 호출에는 이 규칙이 없어서 setLevel(10)은 possible lossy conversion from int to byte 오류가 납니다. Java 언어 명세는 호출에서 상수 축소까지 허용하면 다음에 볼 오버로드 선택 규칙이 더 복잡해지기 때문에 넣지 않았다고 설명합니다. 그래서 byte나 short 매개변수에 상수를 넘길 때는 setLevel((byte) 10)처럼 캐스트해야 합니다.
이름이 같아도 매개변수 목록이 다르면 다른 메서드
이제 이름이 같은 메서드를 여러 개 선언해 보겠습니다. 다음 예제의 세 printAge는 매개변수 타입만 다릅니다.
public final class OverloadSelection {
public static void main(String[] args) {
printAge(40);
printAge(40L);
printAge(40.5);
}
private static void printAge(int value) {
System.out.println("int=" + value);
}
private static void printAge(long value) {
System.out.println("long=" + value);
}
private static void printAge(double value) {
System.out.println("double=" + value);
}
}세 호출은 모두 같은 이름 printAge를 부릅니다. 출력의 앞부분을 보면 실제로 어느 메서드가 실행됐는지 알 수 있습니다.
int=40
long=40
double=40.5자바는 메서드를 이름만으로 구별하지 않고, 이름과 매개변수 목록을 함께 봅니다. 이 둘을 묶어 메서드 시그니처(signature)라고 합니다. 매개변수 목록이 다르다는 것은 매개변수의 개수, 타입, 순서 가운데 하나라도 다르다는 뜻입니다. 세 printAge는 이름이 같아도 매개변수 타입이 int, long, double로 다르므로 시그니처가 서로 다른 별개의 메서드입니다.
호출할 때는 인수의 타입을 봅니다. 40은 int 리터럴, 40L은 long 리터럴, 40.5는 double 리터럴이고, 세 호출 모두 인수 타입과 정확히 같은 매개변수를 가진 메서드가 있으므로 그 메서드가 실행됩니다. 출력 세 줄이 차례로 int=, long=, double=로 시작하는 이유입니다. 한편 printAge(40)의 40은 확대 변환을 거치면 long 메서드와 double 메서드에도 넘길 수 있습니다. 그런데도 int 메서드가 선택된 이유는 다음 절에서 설명합니다.
반환 타입만 다른 메서드는 오버로딩이 아닙니다. 다음 클래스는 컴파일되지 않습니다.
public final class ReturnTypeOnly {
public static void main(String[] args) {
}
private static int total(int[] values) {
return 0;
}
private static long total(int[] values) {
return 0L;
}
}lab/ReturnTypeOnly.java:9: error: method total(int[]) is already defined in class ReturnTypeOnly
private static long total(int[] values) {
^
1 error오류 메시지가 두 번째 메서드를 total(int[])라고만 부른다는 점을 눈여겨보세요. 반환 타입은 메서드를 구별하는 데 쓰이지 않으므로, 컴파일러가 보기에 두 선언은 같은 total(int[])입니다. 반환 타입으로 구별할 수 없는 이유는 호출문을 떠올려 보면 알 수 있습니다. total(values);처럼 반환값을 쓰지 않고 호출할 수도 있고, System.out.println(total(values))처럼 int와 long을 모두 받는 곳에 넘길 수도 있습니다. 이런 호출만 보고는 어느 쪽을 원하는지 정할 수 없습니다.
매개변수 이름만 바꾸는 것도 마찬가지입니다. print(String email, int age)와 print(String name, int count)는 타입과 순서가 같으므로 같은 시그니처이고, 한 클래스에 함께 선언할 수 없습니다.
정확히 맞는 메서드가 없을 때
앞 예제에서 printAge(int)를 지우면 어떻게 될까요? 인수 40은 int이므로 정확히 맞는 메서드는 없지만, 확대 변환을 거치면 long에도 double에도 넘길 수 있습니다.
public final class OverloadWithoutInt {
public static void main(String[] args) {
printAge(40);
}
private static void printAge(long value) {
System.out.println("long=" + value);
}
private static void printAge(double value) {
System.out.println("double=" + value);
}
}int를 받는 메서드가 없어도 컴파일 오류는 나지 않습니다. 두 후보 가운데 어느 쪽이 선택됐는지 출력의 앞부분으로 확인해 보세요.
long=40이처럼 인수를 받을 수 있는 메서드, 즉 적용 가능한 후보가 둘 이상이면 컴파일러는 그중 가장 구체적인(most specific) 것을 고릅니다. 한 후보가 받을 수 있는 인수를 다른 후보도 전부 받을 수 있다면 앞의 후보가 더 구체적입니다. long 값은 double로 자동으로 넓혀지므로, printAge(long)이 받는 인수는 printAge(double)도 모두 받을 수 있습니다. 반대로 double 값은 long으로 자동 변환되지 않으므로 그 역은 성립하지 않습니다. 그래서 printAge(long)이 더 구체적이고, 이 메서드가 선택됩니다. 인수가 기본형 하나일 때는 그 인수를 받을 수 있는 후보 가운데 범위가 가장 좁은 타입이 선택된다고 기억해도 됩니다.
이 판단에 값 40의 크기는 전혀 쓰이지 않는다는 점도 중요합니다. 40이 long에 더 가까워서가 아니라, long과 double이라는 두 타입 사이의 관계만으로 결정됩니다. 앞 절의 printAge(40)이 int 메서드를 고른 것도 같은 규칙입니다. int 값은 long과 double로 넓혀지지만 그 반대는 안 되므로, 셋 가운데 printAge(int)가 가장 구체적입니다. 인수 타입과 정확히 같은 메서드가 있으면 언제나 그것이 선택되는 이유가 여기에 있습니다.
가장 구체적인 후보를 하나로 정할 수 없으면 컴파일러는 고르기를 포기합니다. 다음은 매개변수 두 개의 순서만 다른 오버로드입니다.
public final class AmbiguousNumberOverload {
public static void main(String[] args) {
add(1, 1);
}
private static void add(int first, long second) {
System.out.println("int, long");
}
private static void add(long first, int second) {
System.out.println("long, int");
}
}lab/AmbiguousNumberOverload.java:3: error: reference to add is ambiguous
add(1, 1);
^
both method add(int,long) in AmbiguousNumberOverload and method add(long,int) in AmbiguousNumberOverload match
1 error두 선언은 매개변수 순서가 달라 시그니처가 다르므로 선언 자체에는 문제가 없습니다. 문제는 add(1, 1) 호출입니다. 두 인수가 모두 int라서 두 메서드 모두 적용할 수 있는데, 첫 번째 자리에서는 add(int, long)이 더 구체적이고 두 번째 자리에서는 add(long, int)가 더 구체적입니다. 매개변수가 여럿이면 모든 자리에서 같거나 더 좁은 타입을 가진 후보가 더 구체적인데, 여기에는 그런 후보가 없으니 하나를 고를 수 없고, 컴파일러는 모호한(ambiguous) 호출이라는 오류를 냅니다. 이것은 실행 중에 생기는 예외가 아니라 컴파일 오류이므로 프로그램은 아예 실행되지 않습니다.
값이 아니라 식의 타입이 메서드를 고른다
다음 예제는 같은 값 30을 서로 다른 방법으로 넘깁니다. 출력이 어떻게 나올지 먼저 예상해 보세요.
public final class StaticArgumentType {
public static void main(String[] args) {
int exact = 30;
long widenedBeforeCall = exact;
label(exact);
label(widenedBeforeCall);
label((long) exact);
}
private static void label(int value) {
System.out.println("int overload");
}
private static void label(long value) {
System.out.println("long overload");
}
}세 호출이 넘기는 인수의 모양을 비교해 보세요. 첫째는 int 변수, 둘째는 int 값을 대입해 둔 long 변수, 셋째는 (long) 캐스트를 붙인 식입니다. 실행 결과는 다음과 같습니다.
int overload
long overload
long overload세 호출에 넘긴 값은 모두 30인데 결과가 다릅니다. 오버로드를 고를 때 값이 아니라 인수로 쓴 식(expression)의 타입을 보기 때문입니다. 컴파일러는 프로그램을 실행하기 전에 각 식의 타입을 변수 선언, 리터럴, 캐스트로부터 알아내는데, 이를 컴파일 시점 타입 또는 정적 타입(static type)이라고 합니다.
첫 호출의 exact는 int 변수이므로 label(int)와 label(long) 모두 적용할 수 있고, 그중 더 구체적인 label(int)가 선택되어 int overload가 출력됩니다. 두 번째 호출의 widenedBeforeCall은 long 변수입니다. long은 int로 자동 변환되지 않으므로 적용할 수 있는 후보가 label(long) 하나뿐입니다. 세 번째 호출의 (long) exact도 캐스트한 결과가 long 타입의 식이므로 같은 이유로 label(long)이 선택됩니다.
long 변수에 30처럼 int 범위의 값이 들어 있어도 컴파일러는 int 메서드를 고르지 않습니다. 변수에 어떤 값이 들어 있을지는 실행해 봐야 알 수 있지만, 호출할 메서드는 컴파일할 때 이미 정해지기 때문입니다. 덕분에 같은 호출문은 실행할 때마다 늘 같은 메서드를 부르고, 코드를 읽는 사람도 인수의 타입만 보고 어떤 메서드가 실행될지 알 수 있습니다.
다음 도표는 이 절의 세 호출과, 앞 절에서 printAge(long)과 printAge(double)만 남겼을 때의 printAge(40)을 한데 모아, 인수의 정적 타입에서 적용 가능한 후보를 거쳐 최종 선택까지 이어지는 과정을 보여 줍니다.
exact와 widenedBeforeCall의 값은 모두 30이지만 표현식 타입이 다르다. 각 행에 명시된 선언 집합 안에서 적용 가능한 후보와 선택 결과를 비교한다.
같은 값이어도 표현식 타입에 따라 후보가 달라집니다
값 30을 int와 long으로 전달한 결과를 비교합니다. 호출 전에 정해진 표현식 타입과 선언된 후보 집합으로 오버로드를 선택합니다.
int exact = 30; · long widenedBeforeCall = exact;
| 호출 표현식 | 인수의 정적 타입 | 적용 가능한 후보 | 선택 |
|---|---|---|---|
label(exact) | int · 값 30 | label(int)
| label(int) |
label(widenedBeforeCall) | long · 값 30 | label(long) | label(long) |
label((long) exact) | long · 값 30 | label(long) | label(long) |
printAge(40)별도 후보 집합 | int · 값 40 | printAge(long)
| printAge(long)int 선언 없음 |
앞 세 호출의 선언label(int) · label(long)
label(exact)인수는
int, 값은 30입니다.label(int)와label(long)모두 적용 가능하고label(int)를 선택합니다.label(widenedBeforeCall)인수는
long, 값은 30입니다.label(long)만 적용 가능하며 그 후보를 선택합니다.label((long) exact)캐스트한 표현식은
long, 값은 30입니다.label(long)만 적용 가능하며 그 후보를 선택합니다.printAge(40)· 별도 예제선언을
printAge(long),printAge(double)두 개로 바꾼 예제입니다.int값 40에 둘 다 확대 적용 가능하며printAge(long)을 선택합니다.printAge(int)는 선언되어 있지 않습니다.
이 표는 고정 인수의 정확 일치·primitive 확대만 다룹니다. 값의 크기로 실행 중 선택이 바뀌는 규칙이 아니며, 박싱·가변 인수·제네릭은 포함하지 않습니다.
null을 넘기면 생기는 모호함
참조 타입 매개변수에도 같은 규칙이 적용되는데, 특히 null을 넘길 때 모호한 호출이 자주 생깁니다. null은 어떤 참조 타입 변수에도 넣을 수 있는 값이라서 String 매개변수에도, StringBuilder 매개변수에도 넘길 수 있기 때문입니다.
public final class AmbiguousNullOverload {
public static void main(String[] args) {
describe(null);
}
private static void describe(String value) {
System.out.println("String");
}
private static void describe(StringBuilder value) {
System.out.println("StringBuilder");
}
}lab/AmbiguousNullOverload.java:3: error: reference to describe is ambiguous
describe(null);
^
both method describe(String) in AmbiguousNullOverload and method describe(StringBuilder) in AmbiguousNullOverload match
1 error두 메서드 모두 null을 받을 수 있으니 적용 가능한 후보는 둘입니다. 앞 절의 기준대로라면 한쪽이 받는 값을 다른 쪽이 모두 받을 수 있어야 더 구체적인데, String 객체는 StringBuilder 매개변수에 넘길 수 없고 그 반대도 마찬가지입니다. 두 타입 사이에 이런 포함 관계가 없으니 더 구체적인 후보가 없고, 호출은 모호하다는 컴파일 오류가 됩니다. 메서드가 실행되다가 null 때문에 실패하는 NullPointerException과는 전혀 다른 문제입니다. 이 프로그램은 컴파일 단계에서 멈추므로 실행조차 되지 않습니다.
호출하는 쪽에서 describe((String) null)처럼 캐스트로 인수의 타입을 정해 주면 컴파일되고 String이 출력됩니다. 하지만 이 메서드를 쓰는 사람이 매번 캐스트를 붙여야 한다면, 오버로드끼리 너무 비슷하다는 신호로 봐야 합니다. 이런 모호함은 표준 라이브러리에서도 만날 수 있습니다. System.out.println(null);이라고 쓰면 println(char[])와 println(String)이 둘 다 맞는다는 같은 오류가 납니다.
후보 사이에 포함 관계가 있으면 이야기가 달라집니다. 자바의 모든 클래스는 Object의 한 종류이므로, String 값은 Object 매개변수에 넘길 수 있지만 아무 Object나 String 매개변수에 넘길 수는 없습니다. 이런 클래스 사이의 관계는 상속과 메서드 오버라이딩에서 자세히 배웁니다. 따라서 다음처럼 describe(Object)와 describe(String)이 함께 있으면 describe(String)이 더 구체적인 후보가 됩니다.
public final class NullWithObject {
public static void main(String[] args) {
describe(null);
}
private static void describe(Object value) {
System.out.println("Object");
}
private static void describe(String value) {
System.out.println("String");
}
}이번에는 AmbiguousNullOverload와 달리 컴파일 오류가 나지 않습니다. 두 후보 가운데 더 구체적인 describe(String)이 호출되어 다음이 출력됩니다.
String다음 단계별 도표는 두 경우를 나란히 놓고 비교합니다. 두 경우 모두 이름과 인수 개수가 맞고 null을 받을 수 있는 후보가 둘씩 남지만, 더 구체적인 하나가 있는지에서 처음으로 갈립니다.
null 후보가 둘이어도 더 구체적인 하나가 있으면 선택된다
두 null 호출 사례에서 후보 적용 가능성을 확인한 뒤 구체성이 없으면 컴파일 실패, Object/String이면 String 선택으로 갈립니다. 컴파일 선택 단계와 메서드 실행을 구분하여 확인할 수 있습니다.
A와 B 모두 null에 적용 가능한 후보는 둘이다. A의 String과 StringBuilder는 서로 하위 타입 관계가 없어 더 구체적인 하나가 없다. B의 String과 Object에서는 String이 Object보다 더 구체적이므로 describe(String)을 선택한다.
null도 더 구체적인 후보 하나가 있어야 선택됩니다
같은 null 입력에 적용 가능한 두 후보를 비교합니다. 상속 관계가 없는 두 타입은 모호하고, String과 Object에서는 String을 선택합니다.
A · describe(String) / describe(StringBuilder)
이름·개수 일치 · PASS
describe(null)에 두 선언 모두 이름과 인수 개수가 맞습니다.적용 가능성 · PASS
null은 String과 StringBuilder 모두에 전달할 수 있어 후보 둘이 남습니다.더 구체적인 하나 · FAIL
String과 StringBuilder는 서로 하위 타입 관계가 아닙니다. 하나를 정할 수 없어 컴파일 실패하며 메서드는 실행하지 않습니다.
B · describe(Object) / describe(String)
이름·개수 일치 · PASS
같은
describe(null)에 두 선언 모두 이름과 인수 개수가 맞습니다.적용 가능성 · PASS
null은 Object와 String 모두에 전달할 수 있어 후보 둘이 남습니다.더 구체적인 하나 · PASS
String이 Object보다 구체적입니다.
describe(String)을 선택해String을 출력합니다. 여기가 A와 처음 달라지는 판단입니다.
A에서도 describe((String) null)은 String 후보를 선택합니다. 단, 캐스트가 자주 필요하다면 오버로드의 의도 자체를 검토합니다. 모호한 호출의 컴파일 실패는 NullPointerException이 아닙니다.
이 예의 조건
- A는 String/StringBuilder, B는 Object/String의 별도 오버로드 집합입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | null 후보가 둘이어도 더 구체적인 하나가 있으면 선택된다 같은 null 입력에 대한 두 후보 집합의 적용 가능성과 구체성 비교 A와 B 모두 null에 적용 가능한 후보는 둘이다. A의 String과 StringBuilder는 서로 하위 타입 관계가 없어 더 구체적인 하나가 없다. B의 String과 Object에서는 String이 Object보다 더 구체적이므로 describe(String)을 선택한다. null도 더 구체적인 후보 하나가 있어야 선택됩니다 같은 null 입력에 적용 가능한 두 후보를 비교합니다. 상속 관계가 없는 두 타입은 모호하고, String과 Object에서는 String을 선택합니다. A · describe(String) / describe(StringBuilder) 이름·개수 일치 · PASS describe(null) 에 두 선언 모두 이름과 인수 개수가 맞습니다. 적용 가능성 · PASS null 은 String과 StringBuilder 모두에 전달할 수 있어 후보 둘이 남습니다. 더 구체적인 하나 · FAIL String과 StringBuilder는 서로 하위 타입 관계가 아닙니다. 하나를 정할 수 없어 컴파일 실패하며 메서드는 실행하지 않습니다. B · describe(Object) / describe(String) 이름·개수 일치 · PASS 같은 describe(null) 에 두 선언 모두 이름과 인수 개수가 맞습니다. 적용 가능성 · PASS null 은 Object와 String 모두에 전달할 수 있어 후보 둘이 남습니다. 더 구체적인 하나 · PASS String이 Object보다 구체적입니다. describe(String) 을 선택해 String 을 출력합니다. 여기가 A와 처음 달라지는 판단입니다. A에서도 describe((String) null) 은 String 후보를 선택합니다. 단, 캐스트가 자주 필요하다면 오버로드의 의도 자체를 검토합니다. 모호한 호출의 컴파일 실패는 NullPointerException 이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 적용 가능한 후보 describe(null)에 이름·개수와 참조 적용 가능성을 검사합니다. |
|
|
| 구체성 판정 A는 서로 하위 타입 아님, B는 String이 더 구체적입니다. |
|
|
| 컴파일과 실행 A는 컴파일 실패, B는 선택 메서드를 실행합니다. |
|
|
null 후보가 둘이어도 더 구체적인 하나가 있으면 선택된다
두 null 호출 사례에서 후보 적용 가능성을 확인한 뒤 구체성이 없으면 컴파일 실패, Object/String이면 String 선택으로 갈립니다. 컴파일 선택 단계와 메서드 실행을 구분하여 확인할 수 있습니다.
A와 B 모두 null에 적용 가능한 후보는 둘이다. A의 String과 StringBuilder는 서로 하위 타입 관계가 없어 더 구체적인 하나가 없다. B의 String과 Object에서는 String이 Object보다 더 구체적이므로 describe(String)을 선택한다.
null도 더 구체적인 후보 하나가 있어야 선택됩니다
같은 null 입력에 적용 가능한 두 후보를 비교합니다. 상속 관계가 없는 두 타입은 모호하고, String과 Object에서는 String을 선택합니다.
A · describe(String) / describe(StringBuilder)
이름·개수 일치 · PASS
describe(null)에 두 선언 모두 이름과 인수 개수가 맞습니다.적용 가능성 · PASS
null은 String과 StringBuilder 모두에 전달할 수 있어 후보 둘이 남습니다.더 구체적인 하나 · FAIL
String과 StringBuilder는 서로 하위 타입 관계가 아닙니다. 하나를 정할 수 없어 컴파일 실패하며 메서드는 실행하지 않습니다.
B · describe(Object) / describe(String)
이름·개수 일치 · PASS
같은
describe(null)에 두 선언 모두 이름과 인수 개수가 맞습니다.적용 가능성 · PASS
null은 Object와 String 모두에 전달할 수 있어 후보 둘이 남습니다.더 구체적인 하나 · PASS
String이 Object보다 구체적입니다.
describe(String)을 선택해String을 출력합니다. 여기가 A와 처음 달라지는 판단입니다.
A에서도 describe((String) null)은 String 후보를 선택합니다. 단, 캐스트가 자주 필요하다면 오버로드의 의도 자체를 검토합니다. 모호한 호출의 컴파일 실패는 NullPointerException이 아닙니다.
이 예의 조건
- A는 String/StringBuilder, B는 Object/String의 별도 오버로드 집합입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | null 후보가 둘이어도 더 구체적인 하나가 있으면 선택된다 같은 null 입력에 대한 두 후보 집합의 적용 가능성과 구체성 비교 A와 B 모두 null에 적용 가능한 후보는 둘이다. A의 String과 StringBuilder는 서로 하위 타입 관계가 없어 더 구체적인 하나가 없다. B의 String과 Object에서는 String이 Object보다 더 구체적이므로 describe(String)을 선택한다. null도 더 구체적인 후보 하나가 있어야 선택됩니다 같은 null 입력에 적용 가능한 두 후보를 비교합니다. 상속 관계가 없는 두 타입은 모호하고, String과 Object에서는 String을 선택합니다. A · describe(String) / describe(StringBuilder) 이름·개수 일치 · PASS describe(null) 에 두 선언 모두 이름과 인수 개수가 맞습니다. 적용 가능성 · PASS null 은 String과 StringBuilder 모두에 전달할 수 있어 후보 둘이 남습니다. 더 구체적인 하나 · FAIL String과 StringBuilder는 서로 하위 타입 관계가 아닙니다. 하나를 정할 수 없어 컴파일 실패하며 메서드는 실행하지 않습니다. B · describe(Object) / describe(String) 이름·개수 일치 · PASS 같은 describe(null) 에 두 선언 모두 이름과 인수 개수가 맞습니다. 적용 가능성 · PASS null 은 Object와 String 모두에 전달할 수 있어 후보 둘이 남습니다. 더 구체적인 하나 · PASS String이 Object보다 구체적입니다. describe(String) 을 선택해 String 을 출력합니다. 여기가 A와 처음 달라지는 판단입니다. A에서도 describe((String) null) 은 String 후보를 선택합니다. 단, 캐스트가 자주 필요하다면 오버로드의 의도 자체를 검토합니다. 모호한 호출의 컴파일 실패는 NullPointerException 이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 적용 가능한 후보 describe(null)에 이름·개수와 참조 적용 가능성을 검사합니다. |
|
|
| 구체성 판정 A는 서로 하위 타입 아님, B는 String이 더 구체적입니다. |
|
|
| 컴파일과 실행 A는 컴파일 실패, B는 선택 메서드를 실행합니다. |
|
|
잘 동작하던 호출이 메서드를 하나 추가하는 것만으로 깨질 수 있다는 점도 알아 두어야 합니다. 위의 NullWithObject에 다음 메서드를 추가해 보겠습니다.
private static void describe(StringBuilder value) {
System.out.println("StringBuilder");
}main의 describe(null)은 한 글자도 바뀌지 않았는데 이제 컴파일되지 않습니다. 후보가 Object, String, StringBuilder 셋으로 늘었고, 오류 메시지는 그중 어느 두 메서드가 맞부딪혔는지 알려 줍니다.
src/NullWithObject.java:3: error: reference to describe is ambiguous
describe(null);
^
both method describe(String) in NullWithObject and method describe(StringBuilder) in NullWithObject match
1 errorObject 후보는 여전히 덜 구체적이라 탈락하지만, 남은 String과 StringBuilder 사이에서는 앞의 예제처럼 하나를 고를 수 없기 때문입니다. 비슷한 참조 타입을 받는 오버로드가 늘어날수록 이런 일이 생기기 쉽습니다. 두 메서드가 하는 일이 실제로 다르다면 describeText, describeBuilder처럼 이름을 나누는 편이 안전합니다.
오버로딩을 쓸 때와 이름을 나눌 때
오버로딩이 잘 어울리는 경우와 그렇지 않은 경우를 회원가입 예제로 비교해 보겠습니다. 다음 프로그램은 회원 한 명, 회원 목록, 나이 합계를 모두 print라는 이름으로 출력합니다.
public final class MemberRegistryOverloads {
public static void main(String[] args) {
String[] emails = {"[email protected]", "[email protected]"};
int[] age = {40, 50};
print("[email protected]", 30);
print(emails, age, 2);
print(90L);
}
private static void print(String email, int age) {
System.out.println(email + "=" + age);
}
private static void print(String[] emails, int[] age, int size) {
if (size < 0 || size > emails.length || size > age.length) {
throw new IllegalArgumentException("invalid size");
}
for (int index = 0; index < size; index++) {
print(emails[index], age[index]);
}
}
private static void print(long totalAge) {
System.out.println("totalAge=" + totalAge);
}
}세 print는 인수의 개수부터 서로 달라서, 이름이 같아도 각 호출이 어느 메서드로 가는지 헷갈릴 일이 없습니다. 실행 결과는 다음과 같습니다.
[email protected]=30
[email protected]=40
[email protected]=50
totalAge=90첫 줄은 print("[email protected]", 30)의 결과입니다. 다음 두 줄은 배열 버전이 size만큼, 즉 두 번 반복하면서 한 명짜리 print를 호출해 찍은 것이고, 마지막 줄은 print(90L)의 결과입니다. 배열 버전이 한 명짜리 메서드를 다시 부르기 때문에 이메일=나이라는 출력 형식은 한 곳에만 있습니다. 형식을 바꾸고 싶으면 첫 번째 메서드만 고치면 됩니다.
처음 두 메서드는 오버로딩이 잘 맞는 경우입니다. 둘 다 '회원 정보를 출력한다'는 같은 일을 하고, 인수의 모양이 String, int와 String[], int[], int로 확연히 달라서 호출문만 봐도 어느 쪽인지 바로 알 수 있습니다. 반면 세 번째 print(long totalAge)는 사정이 다릅니다. 이 메서드는 회원이 아니라 나이 합계를 출력하는데, 호출문 print(90L)만 봐서는 무엇을 출력하는지 알 수 없습니다. 게다가 L을 빼고 print(90)이라고 써도 int가 long으로 넓혀져 이 메서드가 선택되고 totalAge=90이 출력됩니다. 회원 한 명을 출력하려다 이메일 인수를 빠뜨려도 오류 없이 엉뚱한 줄이 찍히는 것입니다. 이런 메서드에는 printTotalAge처럼 하는 일을 드러내는 이름을 따로 붙이는 편이 낫습니다.
정리하면 오버로딩은 비슷해 보이는 작업마다 같은 이름을 붙이는 기능이 아닙니다. 호출하는 사람이 같은 일이라고 느끼고, 인수의 차이만으로 어느 메서드인지 분명할 때 씁니다. 저장, 삭제, 요약처럼 하는 일 자체가 다르면 이름도 달라야 합니다. 또 int와 long처럼 확대 변환으로 이어지는 타입끼리, 또는 String과 StringBuilder처럼 null이 양쪽에 맞는 참조 타입끼리 오버로드를 나란히 두면 앞에서 본 것처럼 어느 메서드가 선택될지 예측하기 어려워집니다.
컴파일러가 메서드를 고르는 순서
지금까지 본 내용을 컴파일러가 따르는 순서로 정리하면 다음과 같습니다. 이 글에서 다룬 기본형과 참조형 인수에 맞춰 단순화한 순서입니다.
- 호출한 이름과 이름이 같고 인수 개수가 맞는 메서드를 모읍니다.
- 그중 모든 인수를 매개변수에 넘길 수 있는 메서드만 적용 가능한 후보로 남깁니다. 기본형 인수는 타입이 같거나 확대 변환이 가능해야 하고, 참조형 인수는
null이나String을Object에 넘기듯 그 매개변수에 대입할 수 있어야 합니다. - 적용 가능한 후보가 하나도 없으면 컴파일 오류입니다.
- 후보가 둘 이상이면 가장 구체적인 하나를 고릅니다. 인수 타입과 정확히 같은 메서드가 있으면 그것이 가장 구체적입니다.
- 가장 구체적인 하나를 정할 수 없으면 모호한 호출로 컴파일 오류입니다.
이 과정은 모두 컴파일할 때 끝납니다. 실행 중에 인수의 값을 보고 선택이 바뀌는 일은 없습니다.
더 깊이: 박싱과 가변 인수가 섞일 때
실제 규칙에는 아직 배우지 않은 두 가지 변환이 더 끼어듭니다. 하나는 int 값을 Integer 객체로 감싸는 박싱(boxing)과 그 반대인 언박싱(unboxing)이고, 다른 하나는 int... values처럼 인수를 몇 개든 받는 가변 인수(variable arity) 매개변수입니다. 다음 예제에서 어느 메서드가 선택될지 예상해 보세요.
public final class OverloadPhases {
public static void main(String[] args) {
show(5);
}
private static void show(long value) {
System.out.println("long");
}
private static void show(Integer value) {
System.out.println("Integer");
}
private static void show(int... values) {
System.out.println("int...");
}
}세 후보는 모두 인수 5를 받을 수 있습니다. show(long)은 확대 변환으로, show(Integer)는 박싱으로, show(int...)는 가변 인수로 받습니다. 실행 결과는 다음과 같습니다.
long이름만 보면 int를 감싼 Integer가 가장 가까워 보이지만, 선택되는 것은 long 버전입니다. Java 언어 명세는 적용 가능한 후보를 세 단계로 나눠 찾기 때문입니다. 1단계에서는 박싱·언박싱과 가변 인수 없이 정확한 일치와 확대 변환만으로 후보를 찾고, 여기서 하나라도 찾으면 그중 가장 구체적인 것을 고르고 끝냅니다. 1단계에서 찾지 못했을 때만 2단계에서 박싱·언박싱을 허용하고, 그래도 없으면 3단계에서 가변 인수까지 허용합니다. 이 예제에서는 1단계에서 이미 show(long)을 찾았으므로 show(Integer)와 show(int...)는 검토 대상에 오르지도 않습니다.
이렇게 단계를 나눈 이유는 호환성입니다. 박싱과 가변 인수는 Java 5에서 추가되었는데, 명세는 그 이전에 유효했던 호출이 새 기능 때문에 모호해지지 않도록 단계를 나눴다고 밝힙니다. 앞 절의 단순화한 순서에서 2번이 사실은 이 세 단계로 나뉘어 진행된다고 보면 됩니다. 정확한 규칙은 Java 언어 명세 15.12.2절에 있고, Integer 같은 래퍼 타입은 래퍼 클래스와 박싱·언박싱에서 다룹니다.
정리
- 오버로딩은 이름이 같고 매개변수 목록이 다른 메서드를 한 클래스에 두는 것입니다. 메서드는 이름과 매개변수 목록(개수, 타입, 순서)으로 구별되며, 반환 타입이나 매개변수 이름만 다른 메서드는 함께 선언할 수 없습니다.
- 인수는 매개변수 타입으로 자동으로 넓혀질 수 있지만, 좁히는 변환은 자동으로 일어나지 않습니다. 캐스트로 억지로 맞추면 값이 조용히 바뀔 수 있습니다.
- 적용 가능한 후보가 여럿이면 가장 구체적인 것이 선택되고, 하나로 정할 수 없으면 모호한 호출이라는 컴파일 오류가 납니다.
null인수는 관계없는 참조 타입 오버로드 사이에서 이런 오류를 자주 일으킵니다. - 선택은 인수 값이 아니라 컴파일 시점의 식 타입으로 이루어지며, 컴파일할 때 확정됩니다.
- 같은 일을 하고 인수 모양만으로 의도가 분명할 때만 오버로딩을 쓰고, 하는 일이 다르면 이름을 나눕니다.
연습 문제
total(int first, int second)와total(int[] values, int size)를 오버로드하세요. 배열 버전은size범위를 검사하고long합계를 반환하게 합니다.main에서 두 버전을 모두 호출합니다.
해설 보기
public final class TotalOverloads {
public static void main(String[] args) {
int[] age = {20, 30, 40};
System.out.println(total(20, 30));
System.out.println(total(age, 3));
}
private static long total(int first, int second) {
return (long) first + second;
}
private static long total(int[] values, int size) {
if (size < 0 || size > values.length) {
throw new IllegalArgumentException("invalid size");
}
long result = 0L;
for (int index = 0; index < size; index++) {
result += values[index];
}
return result;
}
}50
90두 메서드는 매개변수가 int, int와 int[], int로 달라서 함께 선언할 수 있습니다. 반환 타입은 둘 다 long이지만, 앞에서 본 것처럼 반환 타입은 오버로드를 구별하는 데 쓰이지 않으므로 상관없습니다.
첫 메서드가 더하기 전에 first를 long으로 바꾸는 이유는 오버플로를 피하기 위해서입니다. first + second를 그대로 쓰면 int끼리 먼저 더한 뒤 그 결과가 long으로 넓혀집니다. 그래서 total(Integer.MAX_VALUE, 1)이 캐스트가 있을 때는 2147483648을 반환하지만, 캐스트가 없으면 이미 넘친 값인 -2147483648을 반환합니다. 피연산자 하나를 long으로 바꿔 두면 다른 쪽도 long으로 넓혀진 뒤 계산됩니다.
- 다음 프로그램의 출력을 예상해 보세요.
public final class PredictOverload {
public static void main(String[] args) {
show(7);
show('A');
show(7.0f);
}
private static void show(long value) {
System.out.println("long=" + value);
}
private static void show(double value) {
System.out.println("double=" + value);
}
}해설 보기
long=7
long=65
double=7.0show(7)의 인수는 int입니다. long과 double 모두 받을 수 있지만 long이 더 구체적이므로 long=7이 나옵니다. show('A')의 인수는 char이고, char도 long과 double로 넓혀질 수 있으므로 같은 이유로 show(long)이 선택됩니다. 이때 문자 A의 코드 값 65가 전달되어 long=65가 출력됩니다. show(7.0f)의 인수는 float인데, float는 long으로 자동 변환되지 않으므로 적용할 수 있는 후보가 show(double) 하나뿐입니다. 그래서 double=7.0이 출력됩니다.
describe(String)과describe(StringBuilder)가 있는 클래스에서describe(null)이 컴파일되지 않습니다. 호출하는 쪽을 고치는 방법과 메서드 설계를 고치는 방법을 하나씩 제시하세요.
해설 보기
호출하는 쪽에서는 describe((String) null)처럼 캐스트로 인수의 정적 타입을 정해 주면 됩니다. 인수의 타입이 String이 되므로 StringBuilder 매개변수에는 넘길 수 없고, 적용 가능한 후보가 describe(String) 하나로 줄어듭니다.
설계 쪽에서는 두 메서드의 이름을 describeText, describeBuilder처럼 나누는 방법이 있습니다. 이름이 다르면 오버로드 후보끼리 경쟁할 일이 없으므로 null을 넘겨도 모호해지지 않고, 나중에 비슷한 메서드를 추가해도 기존 호출이 깨지지 않습니다. 호출하는 사람이 매번 캐스트해야 하는 오버로드라면 대개 이쪽이 더 나은 해결책입니다.