[JAVA] ArrayList 직접 구현하기
ArrayList란
ArrayList는 Collection Framework(컬랙션 프레임워크)란 여러 개의 데이터를 쉽고 효율적으로 관리할 수 있는 표준화된 방법을 제공하는 클래스 중 하나이다. 컬랙션 프레임 워크 중 List 인터페이스에 해당된다.
List 인터페이스만 알아보면 List 인터페이스는 순서가 있는 데이터의 집합이며 데이터의 중복을 허용하는 인터페이스다. 쉽게말해서 확장된 배열이라고 보면된다. 한번 생성되었을 때 크기가 변하지 않는 배열과는 다르게 객체가 추가되어 capacity가 초과되면 capacity가 늘어난다.
위 ArrayList가 설령 크기가 8로 지정되어 있더라고 9로 배열의 크기를 늘린 다음에 저장할 수 있다. 가변적인 배열이라고 생각하면 머릿속에 외우기 더 쉽다.
구현
java.util의 ArrayList는 메소드가 굉장히 많다. 따라서 다 구현하는 것보다는 ArrayList의 특징을 알 수 있는 필수적인 메소드만 구현을 해서 ArrayList가 어떻게 동작하는지 위주로 구현을 해보고자 한다. 내장된 함수를 최대한 사용하지 않고 돌아가는 구조가 눈에 보이게끔 구현을 하였다.
앞서 ArrayList에 기본을 이야기할 때 ArrayList는 List 인터페이스를 구현한 List Collection Class이며 실제로도 java.util.ArrayList파일을 보면 List 인터페이스를 상속받아서 구현하고 있다. 따라서 본 포스팅에서도 ArrayList를 포함한 List Collection Class를 몇가지 더 구현해볼 것이기 때문에 앞으로 구현할 메소드를 List Interface로 직접 만들어 상속받아서 진행하였다.
지금부터 ArrayList의 메소드를 생성하기 위한 기본적인 정의는 다음과 같다. 최대한 공식 docs를 보고 비슷한 방향으로 구현하고자 조건을 정해두었다.
- 데이터 사이에 빈공간을 허용하지 않는다.
- 별다른 요구사항이 없을 경우 요소의 추가는 List 가장 마지막에 추가한다.
- GC로 배열을 관리한다.
구현 목록은 다음과 같다. 조건을 잘 지키면서 구현해보자!
- size()
- isEmpty()
- contains()
- toArray()
- add()
- remove()
- clear()
- get()
- set()
- indexOf(), lastIndexOf()
(코드 가독성을 위해서 javadoc는 지우고 코드를 설명했다. 전체 코드는 github.com/jinyoungchoi95/DataStructure/blob/master/src/DataStructure/List/ArrayList.java에서 확인할 수 있다.)
1. default 세팅
ArrayList 클래스를 생성하고 초기에 말했던 생성해둔 List 인터페이스를 상속한다.
package Week0;
import java.util.Arrays;
public class ArrayList<E> implements List<E> {
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] EMPTY_ELEMENTDATA = {};
transient Object[] elementData;
private int size;
- DEFAULT_CAPACITY : 초기 용량의 default 값으로 지정해준 상수이다. (지금은 10으로 지정을 해두었으니 capacity를 정해주지 않았다면 ArrayList안의 기본 배열은 사용될 때 10부터 시작한다.)
- EMPTY_ELEMENTDATA : 빈 배열이다.
- elementData : 실제 ArrayList에 들어오는 변수들을 담는 배열이다. 직렬화를 방지하기 위해서 transient 지시자를 사용했다.
- size : 데이터가 담겨있는 실제 Capacity(용량)이다. ArrayList의 크기를 나타내는 변수이다. 단 elementData 배열의 크기와는 다르다. 배열에서 남는 공간이 있을 때 항상 딱 맞게 배열의 크기를 조정하는 것이 아니기 때문에 size 변수를 두고 사용한다.
주의해야할 부분은 size라는 변수에 실제 ArrayList에 담긴 변수들의 capacity가 담겨있다는 것이다. elementData 배열의 크기를 넣으면 되는데 굳이 size 변수를 추가로 만들 필요가 있나? 하는 의아함이 들 수 있지만 모두 GC의 역할 때문에 진행되는 사항이다.
GC가 무엇인가? JVM의 Heap 메모리를 사용자 대신 알아서 관리해주는 좋은 녀석이다. 따라서, 사용하고 난 객체에 null을 할당하여서 GC가 메모리관리를 편하게 도와주면 굳이 어렵게 새롭게 배열을 생성하고 그곳에 하나하나 copy할 필요가 없다. 따라서 이런 GC의 원리를 사용하기 때문에 실시간으로 elementData의 크기가 ArrayList의 크기를 대변해주지 못할 때가 많이 생기기 때문에 size라는 ArrayList의 크기를 실시간으로 반영해주는 변수를 만들어 주는 것이다.
2. Constructor
public ArrayList() {
this.elementData = EMPTY_ELEMENTDATA;
}
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}
}
Constructor가 두 개다. 아래의 Constructor를 먼저 살펴보면 매개변수로 원하는 Capacity를 받은 후 0보다 크면 그 값으로 사용할 새로운 배열 객체를 생성하고 0이라면 default인 EMPTY_ELEMENTDATA의 크기를 가진 배열 객체를 생성한다. 이외의 값인 음수가 들어왔다면 예외처리를 한다.
여기까진 코드 내용 그대로 상식적인 내용이고 ArrayList를 자주 사용하는 사람이라면 익숙한 구문이 있다.siz
ArrayList<Integer> list = new ArrayList<>();
Capacity를 따로 할당하지 않는 ArrayList 객체 생성이다. 실제 ArrayList 클래스는 위의 Constructor처럼 입력값을 따로 받지 않아도 initialCapacity가 0일 때와 마찬가지로 default 값으로 알아서 배열 객체의 크기를 지정해 생성한다.
유의해야할 점은 default 세팅에서 언급한대로 elementData 배열 객체가 생성이 되어도 배열 객체는 비어있고, 배열의 크기가 ArrayList의 크기를 뜻하는 것이 아니기 때문에 size 변수는 건들지 않는다.
3. size()
@Override
public int size() {
return size;
}
앞으로 볼 메소드들은 List 인터페이스에서 상속(implements)받아 사용하는 영역으로 List 인터페이스의 메소드를 재정의한 형태를 띈다.
꾸준히 말한대로 ArrayList의 실제 크기는 내부의 배열의 크기와 실시간으로 매칭되지 않기 때문에 size 변수로 전체 클래스에서 ArrayList의 크기를 계산해준다.
뿐만 아니라 size의 크기의 변동률이 잦기 때문에 이렇게 변수로 관리하고 변수만 리턴하는 것이 효율적이라고 한다!
4. isEmpty()
@Override
public boolean isEmpty() {
return size == 0;
}
ArrayList가 비어있는지를 판단하는 메소드이다.
size가 0 이라면 true를, 아니라면 false를 반환한다.
5. contains()
@Override
public boolean contains(Object o) {
if ( o == null) {
for (int i=0; i<size; i++){
if(elementData[i]==null) return true;
}
}
else {
for(int i=0; i<size; i++){
if(o.equals(elementData[i])) return true;
}
}
return false;
}
원하는 특정 변수가 ArrayList내에 존재하는지 아닌지를 판별하는 메소드다.
null을 검사하는 경우에는 for문을 null을 검사하는 반복문을 돌리고 아닌 경우에는 equals를 사용하여 검사한다. int나 char같은 기본형이면 그냥 == 연산자를 사용해서 비교해도 되지만 다른 객체를 비교할 일이 생기기 때문에 반드시 equals로 검사한다.
포함되어있으면 true를 바로 return하고 다 돌렸는데 없으면 false를 return한다.
6. toArray()
@Override
public Object[] toArray() {
return copy(size);
}
@Override
public <T> T[] toArray(T[] a) {
if(a.length < size) {
return (T[]) copy(size);
}
a = partCopy(a);
if (a.length > size){
a[size] = null;
}
return a;
}
private Object[] copy(int len) {
Object[] copy = new Object[len];
for (int i = 0; i < len; i++) {
copy[i] = elementData[i];
}
return copy;
}
private <T> T[] partCopy(T[] a) {
for(int i=0; i<size; i++){
a[i] = (T) elementData[i];
}
return a;
}
ArrayList를 배열로 만드는 메소드인데 개인적으로 이 부분이 구현할 때 제일 이해가 잘 안갔다.
toArray 메소드는 ArrayList를 배열의 형태로 반환해주는 메소드이다. 이미 생성된 배열에 복사해주는 메소드와 아직 생성되지 않은 배열에 ArrayList가 알아서 배열(Object[])를 생성해서 반환해주는 메소드 두가지가 있다.
toArray() : 생성되지 않은 배열에 복사 - public Object[] toArray()
먼저 위에 있는 생성되지 않은 배열에 복사해주는 메소드를 살펴보자. 반환되는 객체의 타입은 ArrayList를 참고하여서 그에 맞는 타입 배열로 반환해주어야하기 때문에 Object[]를 retrun type으로 받는다. ( 따라서 해당 메소드에서 Object가 아닌 다른 타입으로 반환을 하려고 하면 Object가 다른 클래스보다 상위에 있기 때문에 에러가 나므로 주의한다.)
이후 return값은 copy() 메소드로 재정의 했다. Object 배열을 현재 size에 맞게 크기를 할당한 다음 for문으로 copy하는 전형적인 새로운 배열에 복사하는 메소드이다.
import java.util.ArrayList;
public class Application {
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>();
list.add(3);
list.add(4);
Object[] copyList = list.toArray();
System.out.println("copyList type : " + copyList.getClass().getName());
for(int i=0; i<copyList.length; i++){
System.out.printf("copyList[%d] : ", i);
System.out.println(copyList[i] + ", type : "
+ copyList[i].getClass().getName());
}
}
}
// [출력]
// copyList type : [Ljava.lang.Object;
// copyList[0] : 3, type : java.lang.Integer
// copyList[1] : 4, type : java.lang.Integer
해당 메소드를 사용해보면 Object 배열이 생성되었고 copy된 값들은 기존의 list의 변수와 타입을 그대로 받아온 것을 확인할 수 있다.
toArray() : 생성된 배열에 복사
생성되지 않은 배열에 복사하는 것보다 좀 더 복잡하다. 이미 생성되니 배열의 경우 제한된 크기가 생겨저 버리기 때문에 ArrayList의 크기에 맞게 깔끔하게 배열을 생성했던 이전과는 다르게 몇가지 과정이 추가된다.
일단 ArrayList의 크기가 주어진 배열의 크기보다 클 경우 위의 toArray()랑 동일하다. 어짜피 내가 넣을 데이터의 크기는 기존의 배열을 넘어서는 값이기 때문에 ArrayList을 그대로 copy한 배열을 return하는 것이다.
근데 그게 아닐 경우 유실되는 값이 생기기 때문에 조금 주의해야한다. "기존 배열의 크기가 좀 더 크기때문에 ArrayList의 size만큼만 넣어준다"까지는 복사를 한다는 개념이 그대로 들어간다고 볼 수 있는데 ArrayList.toArray()에는 특이하게 size위치에 있는 배열을 null값을 넣어준다.
이게 뭔말이냐...는 다음 예제 코드를 통해서 설명한다.
public class Application {
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>();
list.add(3);
list.add(4);
Integer[] array = new Integer[]{1,2,3,4,5};
array = list.toArray(array);
System.out.println(Arrays.toString(array));
}
}
// [출력]
// [3, 4, null, 4, 5]
분명 나는 [1, 2, 3, 4, 5]의 배열에 [3, 4] 의 ArrayList를 복사했으니 [3, 4, 3, 4, 5]가 나오는 것이 맞는 것같은데 구현된 ArrayList를 통해서는 array[2]
가 null
이 들어가있다. 구현하는 ArrayList의 경우 본인이 생각하는대로 구현하면 되나 본 구현은 실제 ArrayList를 참조하여 만든 ArrayList이기 때문에 왜 이렇게되는지는 docs의 설명으로 알아보자.
Returns an array containing all of the elements in this list in proper sequence (from first to last element); the runtime type of the returned array is that of the specified array. If the list fits in the specified array, it is returned therein. Otherwise, a new array is allocated with the runtime type of the specified array and the size of this list.
If the list fits in the specified array with room to spare (i.e., the array has more elements than the list), the element in the array immediately following the end of the collection is set to
null
. (This is useful in determining the length of the list only if the caller knows that the list does not contain any null elements.)컬랙션의 끝 바로 뒤에 오는 배열의 요소는 null로 설정한다. 이는 List의 길이를 결정하는데 유용하다.
결국 ArrayList의 바로 끝에오는 요소가 배열에서 null로 지정이 되는 것은 단순히 List의 길이를 결정하는 용도이며 별다른 의미는 없다. 따라서 길이가 더 긴 배열에 ArrayList를 복사하는 경우 null이 들어오는 것에 대한 대처를 하여야 한다.
7. add()
@Override
public boolean add(E e) {
resizeCapacity(size + 1);
elementData[size++] = e;
return true;
}
@Override
public void add(int index, E element) {
rangeCheckForAdd(index);
resizeCapacity(size + 1);
fastAdd(index);
elementData[index] = element;
size++;
}
private void resizeCapacity(int minCapacity) {
growUpCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
private void growUpCapacity(int minCapacity) {
if (minCapacity > elementData.length) {
elementData = grow(elementData, minCapacity);
}
}
private Object[] grow(Object[] elementData, int minCapacity) {
Object[] copy = new Object[minCapacity];
for (int i = 0; i < elementData.length; i++) {
copy[i] = elementData[i];
}
return copy;
}
private void fastAdd(int index) {
for (int i = size; i > index; i--) {
elementData[i] = elementData[i - 1];
}
}
add() 메소드도 두 가지 메소드로 overloading한다.
ArrayList의 가장 마지막에 add하는 메소드와 원하는 위치에 add하는 메소드가 있다. 먼저 좀 더 쉬운 boolean add(int index)
메소드부터 뜯어본다.
add() : List의 가장 마지막에 삽입
매우 단순하다. ArrayList의 elementData의 배열을 1만큼 늘리고 배열의 가장 마지막에 매개변수로 들어온 값을 삽입한다. 단, 실제 ArrayList를 참조해서 구현중인 우리는 중요한 요점 하나가 필요하다. default 세팅을 할 때 우리는 배열의 크기가 0이거나 배열의 크기를 지정하지 않았을 때 elementData 배열의 기본 크기를 DEFAULT_CAPACITY=10
으로 지정했었다.
이 과정을 cacluateCapacity()
메소드가 처리해준다.
이 후 배열의 크기를 늘리는데 growUpCapacity()
메소드 에서는 갑자기 주어진 minCapacity가 배열보다 클 때만 늘려준다고 나온다. 지금까지 본 구현글을 처음부터 진행했다면 GC를 바로 떠올릴 수 있어야 한다. GC가 알아서 배열의 크기를 조정하게끔 우리는 배열을 만질 때 사용 후 남은 배열 공간을 null처리를 했었다. 그래서 GC가 동작하여서 해당 배열을 그대로 두었다면 배열이 새로운 요소를 삽입할 공간이 남아있다는 뜻이므로 굳이 배열의 크기를 늘리지 않아도 된다.
그 외에 배열의 크기가 부족해서 늘려야 하는 경우 바로 grow()
메소드를 통해서 제일 마지막 공간 하나를 만들어 준다.
add()
메소드는 배열의 가장 마지막에 특정 요소를 더해주는 메소드 이므로 배열의 resize에 대한 작업이 종료되었다면 가장 마지막에 요소를 넣고 ArrayList의 크기가 늘어났으므로 size
를 1 올려준다.
return 값은 이 과정이 정확하게 이루어졌다면 true이며 ArrayList는 별다른 조건 사항이 없으므로 예외처리가 발생하지 않는다면 항상 true가 return된다.
add() : List의 원하는 위치에 삽입
원하는 index를 추가로 매개변수를 받아서 그 위치에 삽입하는 메소드이다.
resize 메소드는 동일한데 더하는 부분에서 '조금' 복잡해질 뿐이다. 순서는 index위치를 비우고 그 뒤 요소들을 한 칸씩 뒤로 밀어낸 다음 해당 index 위치에 삽입한다.
먼저 원하는 위치 index가 현재 ArrayList의 크기에 맞게 주어졌는지 확인한다. 0보다 작으면 당연히 안되고 size랑 같을 때는 ArrayList의 가장 뒤에 삽입된다는 뜻이기 때문에 다른 rangeCheck와는 다르게 허용해준다.
fastAdd()
메소드가 공간을 비워서 한칸씩 밀어주는 역할을 한다. for문과 배열을 익숙하게 다룬다면 별 무리없이 사용할 수있다.
이후 배열의 해당 index에 매개변수로 주어진 삽입값을 넣고 size
를 1로 올려준다.
두 메소드 "전부 배열을 늘리고 삽입한다."라는 전제조건을 가지고 만든 것이기 때문에 그리 어렵지는 않으나 지금 구현에서는 실제 ArrayList를 구현하는 것이기 때문에 DEFAULT_CAPACITY를 사용하였다. 조금 복잡하지만 그래도 과정을 한번 공부를 해보면 구조를 깨닫는데 더 도움이 될 것이라 생각한다.
8. remove()
@Override
public boolean remove(Object o) {
if (o == null) {
for (int i = 0; i < size; i++) {
if (elementData[i] == null) {
fastRemove(i);
return true;
}
}
}
else {
for (int i = 0; i < size; i++) {
if (o.equals(elementData[i])) {
fastRemove(i);
return true;
}
}
}
return false;
}
@Override
public E remove(int index) {
rangeCheck(index);
E oldValue = (E) elementData[index];
fastRemove(index);
return oldValue;
}
private void fastRemove(int index) {
for (int i = index; i < size - 1; i++) {
elementData[i] = elementData[i + 1];
}
elementData[--size] = null;
}
remove()
메소드는 원하는 위치의 혹은 원하는 요소를 ArrayList에서 제거하는 메소드이다.
remove() : 원하는 위치의 값 제거
먼저 조금 더 쉬운 index값에서 제거하는 메소드를 살펴보자.
rangeCheck()
메소드를 통해 정확한 index값이 들어왔는지 예외처리를 한다.
본 remove()
는 return값으로 삭제되기 전 가지고 있던 요소를 반환한다. 따라서 oldValue
변수에 기존의 요소를 저장한다.
그 후 원하는 index위치의 변수를 제거하는 faseRemove()
메소드를 통해 값을 지워준다. 기존의 elementData에서 index위치부터 한칸씩 뒤로 미뤄서 값을 옮겨주는 간단한 for문이다.
마지막으로는 제일 마지막에 있는 elementData[size]를 GC가 처리하기 쉽도록 null로 처리해둔다.
처리와 동시에 이제 값 하나가 사라졌으니 ArrayList의 크기를 나타내는 size
변수 또한 1 내려준다.
remove() : 원하는 객체 요소 제거
ArrayList에서 값 하나를 제거한다 라는 기능은 같지만 어디에 있는지 index를 모르기 때문에 제거하기 전에 index를 찾아주는 기능을 추가해준 메서드이다. contians()
메소드에서 나왔던 것처럼 for문으로 돌려서 있는지 없는지 체크하며 객체를 검사할 때 == 연산자가 아닌 equals를 사용하여 비교하는 것을 주의한다.
이후 index를 찾으면 fastRemove()
메소드를 이용하여서 제거해준다.
반환 타입은 boolean으로 원하는 객체 요소가 있어 제거를 한 경우에는 true를, 제거하지 못하였으면 false를 return한다.
9. clear()
@Override
public void clear() {
for (int i = 0; i < size; i++) {
elementData[i] = null;
}
size = 0;
}
ArrayList의 모든 값을 지워주는 메소드이다.
elementData를 빈 배열로 만들어 return 하는 것이 아니라 GC가 알아서 처리하게끔 elementData의 모든 요소를 null로 초기화 한다. 그렇게 되면 GC가 판단을 하여서 Heap Area를 관리할 수 있다.
이제 ArrayList 값이 비었으므로 size
를 0으로 초기화 해준다.
10. get()
@Override
public E get(int index) {
rangeCheck(index);
return (E) elementData[index];
}
rangeCheck는 index가 들어왔을 때 현재 size를 넘었는지 안넘었는지 확인하는 예외처리이다.
ArrayList는 배열을 구현한 List 컬랙션이므로 별다른 처리필요 없이 배열의 index위치에 있는 값을 반환해준다.
11. set()
@Override
public E set(int index, E element) {
rangeCheck(index);
E oldValue = (E) elementData[index];
elementData[index] = element;
return oldValue;
}
ArrayList의 특정 위치에 원하는 값을 삽입하는 메소드 이다.
get()
메소드와 마찬가지로 ArrayList는 배열을 구현한 컬랙션이므로 elementData의 index위치에 원하는 값을 삽입한다. 단, 이 메소드의 return값은 이전에 index위치에 있던 변수이므로 oldValue에 이전 변수를 저장한 후 return 해준다.
12. indexOf(), lastIndexOf()
@Override
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++) {
if (elementData[i] == null) return i;
}
} else {
for (int i = 0; i < size; i++) {
if (o.equals(elementData[i])) return i;
}
}
return -1;
}
@Override
public int lastIndexOf(Object o) {
if (o == null) {
for (int i = size - 1; i >= 0; i--) {
if (elementData[i] == null) return i;
}
} else {
for (int i = size - 1; i >= 0; i--) {
if (o.equals(elementData[i])) return i;
}
}
return -1;
}
매개변수로 들어온 원하는 변수가 ArrayList의 어떤 index에 위치하는지 찾아주는 메소드이다. indexOf와 lastIndxOf의 차이는 단순히 배열에서 0부터 검사를 하느냐 배열의 가장 마지막 index부터 검사하느냐이다.
변수가 null로 들어올 경우 null을 찾고 다른 경우 객체를 비교하기 위해서 == 연산자가 아닌 equals 연산자를 사용한다. 찾은 위치 index를 바로 return하고 아무것도 찾지 못했으면 -1을 return한다.
13. contains(), remove() & indexOf()
지금까지 잘 공부를 했다면 여기서 의문점이 들고 개선해야할 점이 눈에 보인다.
indexOf()
메소드와 contains()
메소드가 비슷하다못해 거의 동일하다. 따라서 객체지향 언어인 자바를 사용하고 있기 때문에 contains()
메소드를 indexOf()
메소드를 이용해서 다음과 같이 리팩토링할 수 있다.
@Override
public boolean contains(Object o) {
return (indexOf(o)>=0)? true : false;
}
또 remove도 다음과 같이 리팩토링할 수 있을 것이다.
@Override
public boolean remove(Object o) {
int index = indexOf(o);
if(index>-1){
fastRemove(index);
return true;
}
return false;
}
앞에서도 말했지만 전체 코드는 깃허브에 전부 저장을 해두었습니다.
최대한 공식문서와 실제 class 파일을 참고하여 만들었지만 혹시 모를 이탈자나 잘못된 부분은 댓글로 피드백 부탁드립니다.
[참고 자료]
https://docs.oracle.com/javase/8/docs/api/java/util/List.html (oracle java docs)