회고록 블로그

[공부 필기] Java 기본 공부하기 (33) 본문

2. 프로그래밍 언어 공부/Java

[공부 필기] Java 기본 공부하기 (33)

김간장 2021. 12. 16. 23:24

공부 중인 강의 : 윤성우 선생님, 윤성우의 열혈 Java 프로그래밍 강의.

링크 : https://cafe.naver.com/cstudyjava

 

윤성우의 프로그래밍 스터디그룹 [C/... : 네이버 카페

윤성우의 스터디 공간입니다. C와 JAVA를 공부하시는 분들은 모두 들어오세요. ^^

cafe.naver.com

 

※ 강의 청강 중 필요한 내용만 필기함

※ 틀린 필기가 있을 수 있음..

 


1. 제네릭 (본격적으로 공부하기 전 사전 학습)

- 제네릭은 반드시 이해하고 다음 챕터로 넘어가야함

   → 제네릭은 Java에서 중요한 문법임

 

- 자료형을 결정짓지 않고 기본 형태(틀)를 미리 만들어 놓는 것

   → 이러한 형태의 클래스 정의를 보고 [제네릭]이라고 함

   → C++에서는 이런 문법을 제네릭이 아니라 [템플릿]이라고 부름

- 자료형이 유동적으로 변경되는 것이지, 클래스를 정의 해야하는건 같음

 

- 사실 제네릭은 이렇게 설명되기도 함

   → 만약 제네릭이 없다면?

         * 예시 상황 코드

            (이 프로그램에는 A와 B라는 클래스가 전부임)

            (그리고 PrintObject 클래스는 어떤 객체이든 상관없이 어쨌든 객체를 출력하는 역할을 함)

// 이 프로그램에는 A와 B라는 클래스가 전부라고 가정
class A {
	...
}
class B {
	...
}
class PrintObject {
	...
    // A 객체이거나 B 객체이거나 어쨌든 어떤 객체가 되든 그 객체를 출력하는 코드
    public void printObject(Object o) {
    	return o;
    }
    ...

         * 인스턴스 A를 인자로 받든 인스턴스 B를 인자로 받든

            printObject 메소드는 모든 객체를 인자로 전달받으려면 Object타입으로 매개변수를 정의해야함

         * 하지만 이렇게 Object 타입을 받는 코드는 몇가지 문제가 있음

 

   → 가장 대표적인 문제로는

         만약 PrintObject 클래스 안에서 인스턴스 A나 B의 변수에 접근하는 등을 할 땐 [명시적 형변환]을 해줘야 한다는 점

         * 가령, 아래의 코드와 같이

class PrintObject {
	...
    // A 객체이거나 B 객체이거나 어쨌든 어떤 객체가 되든 그 객체를 출력하는 코드
    public void printObject(Object o) {
    	if(o instanceof A) {
        	A aClass = (A)o; // 형변환
            System.out.println(aClass);
        }
        else if(o instanceof B) {
        	B bCalss = (B)o; // 형변환
            System.out.println(bClass);
        }
        else
        	System.out.println(o);
    }
    ...

   → 그러나 이렇게 명시적 형변환을 하면 코드가 명확해진다는 장점은 있지만

         컴파일러의 오류 발견 가능성이 낮아져 코드의 안정성이 떨어진다는 단점과

         프로그래머가 번거롭게 모두 형변환을 해줘야한다는 단점이 발생함

         * 강제 형변환을 하게 되면 컴파일러는 형변환을 해도되는지 아닌지 판단하지 않기 때문에

            만일 형변환 코드를 작성할 때, 프로그래머가 실수하게 되면 코드의 안정성이 쭉 떨어짐

 

 

# 참고사항

'컴파일러의 오류 발견 가능성'이 낮아지는게 왜 단점일까.

그에 대해서 이해하려면 에러를 정리 할 필요가 있다.

 

에러는 일종의 [안전 장치]이다.

당연히 실수는 누구나 할 수 있는데, 이때 중요한 것은 그 실수를 찾아서 해결했느냐이다.

결국, 컴파일러가 에러를 띄워주는건 '프로그래머나 사용자의 실수'를 찾아주고, 프로그래머가 그걸 수정할 수 있도록 도와주는 것이다.

 

보통 첫번째 안전 장치 역할을 하는게 [컴파일 과정에서 발생하는 에러]이다.

그리고 두번째 안전 장치는 [실행 과정에서 발견되는 예외]

 

사실, 컴파일 과정에서 발생한 에러와 실행(런타임) 과정에서 발견된 에러는 정말 큰 차이가 있다.

컴파일 과정에서 발생한 에러(컴파일 에러)는 눈 감고도(?) 해결을 할 수 있는데

런타임(실행) 과정에서 발생한 예외는 약간 더 시간을 들여 해결 해야한다. 

 

그러나...

이 두 에러보다 더욱 더 오랜 시간을 들여 해결 해야하는 에러가 있는데 그게 바로 [논리적 에러]이다.

컴파일 과정과 실행 과정에서 에러가 발견되지 않았는데 "이상한 결과(예상하지 않았던 결과)"가 출력되는 경우가 이에 해당한다.

이 경우에는 코드를 뜯어 고쳐야하며, 어쩌면 퇴근이 어려울지도 모른다..

 

때문에 논리적 에러로 고통받고 싶지 않다면(?)

코드를 작성할 땐, 최대한 컴파일 과정과 실행(런타임) 과정에서 실수를 잡을 수 있도록 작성해야한다.

 

즉, 컴파일러가 오류를 발견할 가능성이 낮아질수록 논리적 에러를 만날 가능성이 높아진다는 이야기이다.

##

 

2. 제네릭 (본격 학습)

- 아래와 같은 코드가 있다고 가정

public class Box {
	Object obj;
	
	public void set(Object o) {
		obj = o;
	}
	
	public Object get() {
		return obj;
	}
}

   → 어찌보면 매개변수를 모든 인스턴스(Object)로 하고, 모든 인스턴스를 멤버 변수로 세팅할 수 있는 [만능 클래스]같지만

         사실 이 코드는 그렇게 좋은 코드라고 말할 수 없음

   → 왜냐하면 받아 들이는 인스턴스에 제한을 두지 않는다는 것은 에러가 발생할 수 있는 가능성이 높다는 얘기이기 때문

         * 이 때문에 프로그래머는 더 예민(?)하고 주의해서 코드를 작성해야하고 신경 쓸 점이 많아짐

 

- 따라서 제한을 두는게 더 좋은 코드임

   → 예를 들면 클래스를 인스턴스화 하는 순간에 어떤 인스턴스만을 받을 것인지 결정하고,

         멤버 변수 obj의 자료형은 어떤 객체로 할 것인지 결정하기 등

 

- 이 예시의 내용이 바로 [제네릭]

   → 즉, Box 인스턴스가 생성될 때 그 타입(자료형)을 결정하겠다는 것

 

- 제네릭을 코드로 구현하면 아래와 같음

   → 일단 Box 클래스의 "Object" 타입을 모두 지움

         * 여기에서 지울 대상은 '클래스 내의 모든 자료형'이 아님 (즉, 모든 자료형을 지울 필요는 없음)

         * '인스턴스 생성 시에 자료형이 결정되도록 하고 싶은 곳'만 지우면 됨

public class Box {
	________ obj;
	
	public void set(________ o) {
		obj = o;
	}
	
	public ________ get() {
		return obj;
	}
}

   → 그리고 지워져 있는 부분(자료형이 적혀있어야 하는 위치)에 "T" 라는 문자를 집어넣음

         * 'T'가 적힌 곳의 정보(=자료형)는 Box 인스턴스가 생성될 때 결정하겠다는 의미

public class Box {
	T obj;
	
	public void set(T o) {
		obj = o;
	}
	
	public T get() {
		return obj;
	}
}

   → 마지막으로 Box 클래스 이름 옆에 "<T>"를 적음

        * 자료형이 있어야 하는 위치에 "T"가 적혀있으면

           컴파일러는 프로그래머가 "T라는 클래스 이름을 적은 것인지, 제네릭을 표현한 것인지" 알 수가 없음

        * 따라서, 컴파일러에게 제네릭을 표현한 것을 명확하게 알려주기 위해서 클래스 이름 옆에 "<T>"를 삽입함

public class Box<T> {
	T obj;
	
	public void set(T o) {
		obj = o;
	}
	
	public T get() {
		return obj;
	}
}

 

- 제네릭 클래스 기반 인스턴스를 생성하는 방법

   → "Box 인스턴스를 생성하는데, Box 객체 안에 있는 'T'는 모두 'Person'으로 자료형을 설정하겠다'는 의미

// 메인 메소드
Box<Person> h = new Box<Person>();

- 이 순간 만큼은 Box 클래스가 아래와 같이 정의된 것과 마찬가지임

   → 물론 상속 관계에 따라 Person 혹은 Person을 상속하는 자식 클래스의 인스턴스를 obj에 저장할 수 있게 됨 

public class Box {
	Person obj;
	
	public void set(Person o) {
		obj = o;
	}
	
	public Person get() {
		return obj;
	}
}

 

- 위의 예시들을 기준으로 용어 정리

   → Box<T>의 "T"는 [타입 매개변수]라고 함

        * "매개변수(parameter)"라는 단어가

           "함수를 선언할 때, '함수에 들어올 값이 어떤 자료형의, 어떤 이름으로 불릴지' 정의한 변수"라는 점을 생각해보면

           왜 '타입 매개변수'라고 부르는지 알 수 있음

 

   → Box<Person> 인스턴스를 생성할 때의 코드 Box<Person>에서 "Person"은 [타입 인자]라고 함

        * "인자(argument)"라는 단어가 "함수 호출 시, 함수에 전달하는 값"이라는 점을 생각해보면

           왜 '타입 인자'라고 부르는지 알 수 있음

 

   → Box<Person>은 [매개변수화 타입]이라고 함

         * Box<Person>은 정확하게 말하면 "Box"형이 아님

         * 컴파일러는 Box<Person>을 별도의 타입(자료형)으로 인식함 (Box와 다른 타입)

         * "기본 자료형(Primitive Data Types)"이라는 용어가 있는 것 처럼

            Box<Person>은 "매개변수화 타입(Parameterized Type)"이라고 부름

 

 

- 제네릭이 존재할 때의 장점

   → 앞서 배운 '제네릭이 없을 때 생기는 단점(컴파일러의 오류 발견 가능성이 낮아지는 문제)'을 해결할 수 있음

   → 문제가 됐던 코드를 다시 가져와봤음

// 이 프로그램에는 A와 B라는 클래스가 전부라고 가정
class A {
	...
}
class B {
	...
}
class PrintObject {
	...
    // A 객체이거나 B 객체이거나 어쨌든 어떤 객체가 되든 그 객체를 출력하는 코드
    public void printObject(Object o) {
    	if(o instanceof A) {
        	A aClass = (A)o; // 형변환
            System.out.println(aClass);
        }
        else if(o instanceof B) {
        	B bCalss = (B)o; // 형변환
            System.out.println(bClass);
        }
        else
        	System.out.println(o);
    }
    ...

   → 이 코드를 제네릭으로 바꾸면 아래와 같아짐

class PrintObject<T> {
	...
    // A 객체이거나 B 객체이거나 어쨌든 어떤 객체가 되든 그 객체를 출력하는 코드
    public void printObject(T o) {
        	System.out.println(o);
    }
    ...

 

   → printObject<T> 클래스를 가지고 메인 메소드에서 인스턴스를 생성하면 아래와 같이 생성할 수 있음

        * A객체나 B객체로 "강제 형변환"을 할 필요가 없음

        * 만약 참조변수 a의 printObject 메소드를 호출하는데, 인자로 "B 인스턴스"를 전달하면 이런 에러가 발생함

           The method printObject(A) in the type Box<A> is not applicable for the arguments (B)

        * 따라서, JVM이 에러를 띄워줌으로써 프로그래머의 실수를 방지할 수 있음

// 메인 메소드
PrintObject<A> a = new PrintObject<A>();
PrintObject<B> b = new PrintObject<B>(); // PrintObject<B> 생성 시 인자로 전달되는 값 없음

a.printObject(new A()); // A 인스턴스를 생성 후 printObject의 인자로 전달
b.printObject(new B());

 

# 참고사항

만약, 제네릭을 사용하지 않은 코드에서 프로그래머가 실수를 한다면 어떻게 될까.

아래의 제네릭을 사용하지 않은 코드를 보자.

class PrintObject {
	...
    // A 객체이거나 B 객체이거나 어쨌든 어떤 객체가 되든 그 객체를 출력하는 코드
    public void printObject(Object o) {
    	if(o instanceof A) {
        	A aClass = (A)o; // 형변환
            System.out.println(aClass);
        }
        else if(o instanceof B) {
        	B bCalss = (B)o; // 형변환
            System.out.println(bClass);
        }
        else
        	System.out.println(o);
    }
    ...

이때 프로그래머가 실수를 하게 되면.. 그대로 "B 인스턴스"가 인자로 전달된 결과가 출력된다.

// 메인 메소드
PrintObject p = new PrintObject();
...
(무수히 많은 코드들 중략)
...
p.printObject(new B()); // 프로그래머는 "A" 인스턴스를 인자로 전달하고 싶었으나 실수했음

 

만약, 코드의 라인이 굉장히 많고, 인스턴스가 많이 생성되어 있는 코드가 있는데

오늘 PrintObject 인스턴스를 생성했다가

며칠후에 (PrintObject 인스턴스의) printObject 메소드를 호출해야하는 상황이라면,

과연 "인자로 'B 인스턴스'를 전달하는 실수"를 하지 않을 수 있을까.

 

간단한 코드는 실수할 일이 적겠지만, 코드가 많아지고 복잡해질수록 실수가 생길 수 있다.

반면에 제네릭이 있으면, 인스턴스를 생성할 때 실수만 하지 않으면 컴파일러가 문제점을 찾아준다.

 

아래와 같이 제네릭 코드가 있다고 하면...

class PrintObject<T> {
	...
    // A 객체이거나 B 객체이거나 어쨌든 어떤 객체가 되든 그 객체를 출력하는 코드
    public void printObject(T o) {
        	System.out.println(o);
    }
    ...

프로그래머가 실수해도 컴파일러가 에러를 띄워 알려준다.

// 메인 메소드
PrintObject<A> p = new PrintObject<A>(); // 프로그래머는 "A"를 타입 인자로 설정함
...
(무수히 많은 코드들 중략)
...
p.printObject(new B()); //프로그래머는 "A" 인스턴스를 인자로 전달하고 싶었으나 실수했음
The method printObject(A) in the type Box<A> is not applicable for the arguments (B)

##

Comments