자바 배열 - 선언 초기화 다차원 배열과 크기가 고정이라는 한계 (자바 초급 4단원)
이 단원에서 배우는 것
3단원까지는 책 한 권을 다뤘다. title 하나, overdueDays 하나. 그런데 도서관에는 책이 여러 권이다. 변수 이름을 title1, title2, title3 로 늘려 가는 방식은 세 권째부터 무너진다. 반복문으로 훑을 수도 없다. 이번 단원은 그 문제를 배열로 푼다.
그리고 배열을 다 배우고 나면 배열이 못 하는 것이 보인다. 그 지점이 중급 단원의 컬렉션으로 넘어가는 다리다.
- 배열을 선언·초기화하고, 초기화하지 않은 원소에 무엇이 들어 있는지 안다.
- 인덱스
for와 향상된for를 구분해 쓰고,java.util.Arrays의 도구를 쓴다. - 배열이 참조형이라는 사실이 복사와 비교에서 무엇을 바꾸는지 설명한다.
왜 필요한가
변수 이름으로는 반복을 못 돈다
도서 세 권의 연체료 합계를 구한다고 하자. 변수를 따로 두면 이렇게 된다.
int overdueDays1 = 0;
int overdueDays2 = 12;
int overdueDays3 = 3;
int total = overdueDays1 + overdueDays2 + overdueDays3;
책이 100권이 되면 줄이 100개가 된다. 더 나쁜 것은 3단원에서 배운 반복문을 쓸 수 없다는 점이다. for 안에서 i 를 1, 2, 3 으로 바꾼다고 overdueDays1 이라는 이름이 만들어지지는 않기 때문이다. 변수 이름은 컴파일 시점에 고정되고, 반복문의 값은 실행 시점에 정해진다.
배열은 이 간격을 메운다. 값들을 이름이 아니라 번호로 구분하니까, 번호를 반복문 변수로 만들 수 있다.
int[] overdueDays = {0, 12, 3};
int total = 0;
for (int i = 0; i < overdueDays.length; i++) {
total += overdueDays[i];
}
System.out.println(total); // 15
이제 책이 100권이어도 이 코드는 그대로다.
문법과 예제
선언과 초기화
배열을 만드는 방법은 세 가지다. 셋 다 실무에서 다 쓰인다.
// (1) 값을 이미 알고 있을 때
String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};
// (2) 크기만 정하고 나중에 채울 때
int[] overdueDays = new int[3];
overdueDays[0] = 0;
overdueDays[1] = 12;
overdueDays[2] = 3;
// (3) 이미 선언된 변수에 새 배열을 통째로 대입할 때
overdueDays = new int[]{0, 12, 3};
String[] titles 처럼 대괄호를 타입 쪽에 붙인다. String titles[] 도 문법상 되지만, 타입 정보가 이름 뒤로 흩어져서 읽기 나쁘다. 실무 코드는 앞의 형태로 통일한다.
(1) 의 중괄호 형태는 선언과 동시에만 쓸 수 있다. 나중에 대입할 때는 (3) 처럼 new int[] 를 앞에 붙여야 한다.
초기화하지 않은 원소의 값
new 로 만든 배열은 비어 있는 게 아니라 타입별 기본값으로 채워져 있다. 2단원 표의 기본값 열이 여기서 쓰인다.
import java.util.Arrays;
public class Defaults {
public static void main(String[] args) {
int[] nums = new int[3];
String[] strs = new String[2];
boolean[] flags = new boolean[2];
double[] rates = new double[2];
System.out.println(Arrays.toString(nums)); // [0, 0, 0]
System.out.println(Arrays.toString(strs)); // [null, null]
System.out.println(Arrays.toString(flags)); // [false, false]
System.out.println(Arrays.toString(rates)); // [0.0, 0.0]
}
}
참조형 배열의 기본값이 null 이라는 점이 중요하다. new String[2] 를 만들고 바로 strs[0].length() 를 부르면 NullPointerException 이다. "배열을 만들었으니 안이 채워졌다"는 감각이 여기서 어긋난다.
훑기: 인덱스 for 와 향상된 for
3단원에서 미뤄 둔 향상된 for 를 이제 쓴다. 원소를 순서대로 꺼내 주기만 하면 될 때는 이쪽이 짧고 실수가 적다.
import java.util.Arrays;
public class LoanReport {
public static void main(String[] args) {
String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};
int[] overdueDays = {0, 12, 3};
// 인덱스가 필요할 때: 두 배열을 나란히 봐야 한다
for (int i = 0; i < titles.length; i++) {
System.out.println(titles[i] + " / " + overdueDays[i] + "일");
}
// 인덱스가 필요 없을 때
int total = 0;
for (int d : overdueDays) {
total += d;
}
System.out.println("연체일 합계 " + total); // 15
}
}
자바의 정석 / 0일
이펙티브 자바 / 12일
클린 코드 / 3일
연체일 합계 15
향상된 for 는 읽기 전용이라고 생각하는 편이 안전하다. for (int d : overdueDays) { d = 0; } 는 배열을 바꾸지 않는다. d 는 원소를 복사해 담은 별개의 변수이기 때문이다. 원소를 고쳐야 하면 인덱스 for 를 쓴다.
3단원 연체료 정책을 여러 권에 적용하기
public class LateFeeReport {
public static void main(String[] args) {
String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};
int[] overdueDays = {0, 12, 3};
int[] fees = new int[titles.length];
for (int i = 0; i < titles.length; i++) {
int days = overdueDays[i];
if (days <= 0) {
fees[i] = 0;
} else if (days <= 7) {
fees[i] = days * 100;
} else {
fees[i] = 700 + (days - 7) * 200;
}
}
int sum = 0;
for (int fee : fees) {
sum += fee;
}
for (int i = 0; i < titles.length; i++) {
System.out.println(titles[i] + " : " + fees[i] + "원");
}
System.out.println("합계 : " + sum + "원");
}
}
자바의 정석 : 0원
이펙티브 자바 : 1700원
클린 코드 : 300원
합계 : 2000원
java.util.Arrays
배열을 다룰 때 매번 손으로 짜지 말아야 할 것들이 표준 라이브러리에 있다. 자주 쓰는 다섯 개다.
import java.util.Arrays;
public class ArraysDemo {
public static void main(String[] args) {
int[] days = {0, 12, 3};
System.out.println(Arrays.toString(days)); // [0, 12, 3]
int[] bigger = Arrays.copyOf(days, 5);
System.out.println(Arrays.toString(bigger)); // [0, 12, 3, 0, 0]
int[] sorted = Arrays.copyOf(days, days.length);
Arrays.sort(sorted);
System.out.println(Arrays.toString(sorted)); // [0, 3, 12]
System.out.println(Arrays.toString(days)); // [0, 12, 3] 원본은 그대로
int[] filled = new int[4];
Arrays.fill(filled, 100);
System.out.println(Arrays.toString(filled)); // [100, 100, 100, 100]
System.out.println(Arrays.equals(new int[]{1, 2}, new int[]{1, 2})); // true
}
}
Arrays.sort 는 원본을 직접 바꾼다. 원본이 필요하면 위처럼 copyOf 로 사본을 뜬 뒤 정렬한다.
다차원 배열
자바에 진짜 2차원 배열은 없다. 배열을 담은 배열이 있을 뿐이다. 이 사실이 문법의 여러 곳에서 드러난다. 도서관 서가를 예로 든다. 책장이 3개, 각 책장에 칸이 4개다.
import java.util.Arrays;
public class Shelf {
public static void main(String[] args) {
int[][] shelf = new int[3][4]; // 책장 3개, 각 4칸, 값은 책 권수
shelf[0][0] = 5;
shelf[2][3] = 9;
System.out.println(Arrays.deepToString(shelf));
// [[5, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 9]]
System.out.println(shelf.length); // 3 책장 수
System.out.println(shelf[0].length); // 4 0번 책장의 칸 수
for (int i = 0; i < shelf.length; i++) {
for (int j = 0; j < shelf[i].length; j++) {
System.out.print(shelf[i][j] + " ");
}
System.out.println();
}
}
}
안쪽 배열의 길이는 서로 달라도 된다. 이걸 가변 배열(jagged array)이라고 부른다. 책장마다 칸 수가 다른 상황이 정확히 이 모양이다.
int[][] jagged = new int[3][]; // 바깥 크기만 정한다
jagged[0] = new int[]{1, 2};
jagged[1] = new int[]{3};
jagged[2] = new int[]{4, 5, 6};
for (int[] row : jagged) {
System.out.print(row.length + " ");
}
System.out.println(); // 2 1 3
그래서 2차원 배열을 훑을 때 안쪽 조건은 shelf[0].length 가 아니라 shelf[i].length 로 써야 한다. 사각형이라고 가정한 코드는 가변 배열을 만나는 순간 ArrayIndexOutOfBoundsException 으로 죽는다.
배열의 한계와 컬렉션
배열의 크기는 new 하는 순간 정해지고 절대 안 바뀐다. 책을 한 권 더 등록하려면 더 큰 배열을 새로 만들어 전부 복사해야 한다.
String[] titles = {"자바의 정석", "클린 코드"};
// titles[2] = "이펙티브 자바"; // ArrayIndexOutOfBoundsException
String[] bigger = Arrays.copyOf(titles, titles.length + 1);
bigger[2] = "이펙티브 자바";
titles = bigger; // 매번 이 짓을 해야 한다
중간에서 하나를 빼는 것은 더 번거롭다. 뒤의 원소를 전부 한 칸씩 앞으로 당기고, 배열을 하나 줄여 다시 만들어야 한다. 이걸 매번 손으로 쓰면 반드시 어딘가에서 인덱스를 틀린다.
그 작업을 미리 해 둔 것이 컬렉션이다. 미리보기로만 보여 준다. 문법 설명은 중급 단원에서 한다.
import java.util.ArrayList;
import java.util.List;
public class Preview {
public static void main(String[] args) {
List<String> titles = new ArrayList<>();
titles.add("자바의 정석");
titles.add("클린 코드");
titles.add("이펙티브 자바");
titles.remove("클린 코드");
System.out.println(titles); // [자바의 정석, 이펙티브 자바]
System.out.println(titles.size()); // 2
}
}
정리하면 이렇다. 크기가 처음부터 끝까지 고정이고 기본형을 많이 담아야 하면 배열이 여전히 최선이다. 넣고 빼는 일이 있으면 컬렉션이다. 실무에서 새로 쓰는 코드는 대부분 후자다.
실무에서 자주 틀리는 것
1. length 와 length() 를 혼동한다
배열은 필드 length, 문자열은 메서드 length() 다. 규칙이 아니라 그냥 역사적 결과라서 외우는 수밖에 없다.
String[] titles = {"자바의 정석", "클린 코드"};
System.out.println(titles.length); // 2 배열은 필드
System.out.println(titles[0].length()); // 6 문자열은 메서드
// titles.length() 컴파일 에러
// titles[0].length 컴파일 에러
2. 마지막 인덱스를 length 로 쓴다
인덱스는 0 부터 시작하므로 마지막은 length - 1 이다. <= 로 쓰면 딱 한 칸을 넘긴다.
String[] titles = {"자바의 정석", "이펙티브 자바", "클린 코드"};
for (int i = 0; i <= titles.length; i++) { // 틀림
System.out.println(titles[i]);
}
// Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException:
// Index 3 out of bounds for length 3
예외 메시지가 친절하게 알려 준다. Index 3 out of bounds for length 3 을 보면 조건의 부등호를 먼저 의심한다.
3. 배열을 == 나 equals 로 비교한다
배열은 참조형이다. 2단원에서 참조형 변수에는 주소가 들어 있다고 했다. 그래서 == 는 내용이 아니라 같은 배열이냐를 묻는다. 더 나쁜 건 equals 도 마찬가지라는 점이다. 배열은 equals 를 따로 정의하지 않아서 결국 주소 비교로 떨어진다.
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
System.out.println(a == b); // false
System.out.println(a.equals(b)); // false ← 함정
System.out.println(Arrays.equals(a, b)); // true ← 이걸 쓴다
같은 이유로 배열을 그냥 출력하면 내용이 안 나온다.
int[] days = {0, 12, 3};
System.out.println(days); // [I@1b6d3586 같은 것
System.out.println(Arrays.toString(days)); // [0, 12, 3]
// 2차원은 deepToString
System.out.println(Arrays.deepToString(new int[][]{{1, 2}, {3}})); // [[1, 2], [3]]
4. 배열을 대입하면 복사되는 줄 안다
배열 변수를 다른 변수에 대입하면 주소만 복사된다. 두 변수가 같은 배열을 가리키므로 한쪽으로 고치면 다른 쪽도 바뀐다.
int[] original = {1, 2, 3};
int[] alias = original; // 복사가 아니다
alias[0] = 99;
System.out.println(Arrays.toString(original)); // [99, 2, 3]
int[] copy = Arrays.copyOf(original, original.length); // 진짜 사본
copy[0] = 1;
System.out.println(Arrays.toString(original)); // [99, 2, 3] 영향 없음
이 성질이 5단원에서 메서드에 배열을 넘길 때 다시 나온다. 왜 메서드 안에서 배열 내용을 고치면 밖에도 반영되는지가 여기서 설명된다.
덧붙이면 Arrays.copyOf 도 한 겹만 복사한다. 2차원 배열에 쓰면 안쪽 배열들은 여전히 공유된다. 깊은 복사는 한 줄씩 직접 복사해야 한다.
스스로 확인하기
int[] fees = new int[5];로 만든 배열을 그대로 출력하면 무엇이 나오는가? 그리고[0, 0, 0, 0, 0]이 나오게 하려면 어떻게 해야 하는가?- 연체일 배열
{0, 12, 3, 25, 5}에서 가장 큰 값과 그 인덱스를 구하는 코드를 쓰라.Arrays.sort는 쓰지 않는다. - 아래 코드는 왜
ArrayIndexOutOfBoundsException이 나는가? 한 글자만 고쳐 해결하라.int[][] shelf = new int[3][]; shelf[0] = new int[]{1, 2, 3, 4}; shelf[1] = new int[]{5, 6}; shelf[2] = new int[]{7}; for (int i = 0; i < shelf.length; i++) { for (int j = 0; j < shelf[0].length; j++) { System.out.print(shelf[i][j] + " "); } }
정답
[I@로 시작하는 타입 기호와 해시값이 찍힌다. 배열이 자기 내용을 문자열로 만드는 방법을 갖고 있지 않기 때문이다.System.out.println(Arrays.toString(fees));로 출력한다.- 첫 원소를 최댓값 후보로 잡고 나머지를 비교해 나간다.
후보를int[] days = {0, 12, 3, 25, 5}; int maxValue = days[0]; int maxIndex = 0; for (int i = 1; i < days.length; i++) { if (days[i] > maxValue) { maxValue = days[i]; maxIndex = i; } } System.out.println("최대 " + maxValue + "일, 인덱스 " + maxIndex); // 최대 25일, 인덱스 30이나Integer.MIN_VALUE대신days[0]으로 잡는 이유는 음수만 든 배열에서도 맞기 때문이다. - 안쪽 반복 조건이
shelf[0].length로 고정돼 있어서, 0번 책장의 길이 4를 1번·2번 책장에도 적용한다. 1번 책장은 길이가 2뿐이라 인덱스 2에서 터진다.shelf[0].length를shelf[i].length로 고친다.
다음 단원에서는 지금까지 main 안에 몰아넣은 연체료 계산을 별도의 메서드로 떼어낸다. 그 과정에서 자바가 매개변수를 어떻게 전달하는지, 왜 배열은 바뀌고 int 는 안 바뀌는지가 분명해진다.