학습할 것 (필수)
- 제네릭 사용법
- 제네릭 주요 개념 (바운디드 타입, 와일드 카드)
- 제네릭 메소드 만들기
- Erasure
참고자료
제너릭이란?
자바에서 제네릭이란 데이터의 타입을 일반화한다는 것을 의미한다. 제네릭은 클래스나 메소드에서 사용할 내부 데이터 타입을 컴파일 시에 미리 지정하는 방법이다. 컴파일에 미리 타입 검사를 수행하면 다음과 같은 장점을 얻을 수 있다.
- 클래스나 메소드 내부에서 사용되는 객체의 타입 안정성을 높일 수 있다.
- 반환값에 대한 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있다.
JDK 1.5 이전에는 여러 타입을 사용하는 클래스나 메소드는 Object 타입을 사용하였다. Object를 사용하게 되면 Object 객체를 다시 원하는 타입으로 형 변환해줘야 한다. 그런데, 실수로 형 변환을 하지 않거나 다른 타입으로 변환할 수도 있다. 문제는 이런 오류는 런타임에 알 수 있다는 것이다.
제네릭을 사용하므로서, 컴파일 타임에 미리 체크할 수 있다.
public class BoxMain {
public static void main(String[] args) {
int value = 10;
BoxObject boxObject = new BoxObject();
boxObject.setElement(value);
String object = (String) boxObject.getElement();
BoxGeneric<Integer> boxGeneric = new BoxGeneric<>();
boxGeneric.setElement(value);
String object2 = boxGeneric.getElement();
}
}
public class BoxObject {
private Object element;
public Object getElement() {
return element;
}
public void setElement(Object element) {
this.element = element;
}
}
public class BoxGeneric<T> {
private T element;
public T getElement() {
return element;
}
public void setElement(T element) {
this.element = element;
}
}
// 컴파일 에러
// java: incompatible types: java.lang.Integer cannot be converted to java.lang.String
BoxObject
에서는 Object
객체를 반환하기 때문에 컴파일 타임에 에러를 확인할 수 없다. 반면, BoxGeneric
은 type argument
로 받았던 Integer
를 반환하기 때문에 String
타입과 일치하지 않아, 컴파일 에러를 발생시킨다.
제네릭 바이트 코드
public class BiteCode <T>{
private T t;
public void setT(T t){
this.t = t;
}
public T getT(){
return t;
}
public static void main(String[] args) {
BiteCode<Integer> biteCode = new BiteCode<>();
biteCode.setT(10);
Integer i = biteCode.getT();
}
}
제네릭 타입이 컴파일되었을 때 type parameter
는 어떻게 컴파일 될 지 궁금하였다. 그래서 바이트 코드를 확인하였다.
public class com/jm/test/BiteCode {
private Ljava/lang/Object; t
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lcom/jm/test/BiteCode; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
public setT(Ljava/lang/Object;)V
L0
LINENUMBER 6 L0
ALOAD 0
ALOAD 1
PUTFIELD com/jm/test/BiteCode.t : Ljava/lang/Object;
L1
LINENUMBER 7 L1
RETURN
L2
LOCALVARIABLE this Lcom/jm/test/BiteCode; L0 L2 0
LOCALVARIABLE t Ljava/lang/Object; L0 L2 1
MAXSTACK = 2
MAXLOCALS = 2
public getT()Ljava/lang/Object;
L0
LINENUMBER 9 L0
ALOAD 0
GETFIELD com/jm/test/BiteCode.t : Ljava/lang/Object;
ARETURN
L1
LOCALVARIABLE this Lcom/jm/test/BiteCode; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
public static main([Ljava/lang/String;)V
L0
LINENUMBER 12 L0
NEW com/jm/test/BiteCode
DUP
INVOKESPECIAL com/jm/test/BiteCode.<init> ()V
ASTORE 1
L1
LINENUMBER 13 L1
ALOAD 1
BIPUSH 10
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
INVOKEVIRTUAL com/jm/test/BiteCode.setT (Ljava/lang/Object;)V
L2
LINENUMBER 14 L2
ALOAD 1
INVOKEVIRTUAL com/jm/test/BiteCode.getT ()Ljava/lang/Object;
CHECKCAST java/lang/Integer
ASTORE 2
L3
LINENUMBER 15 L3
RETURN
L4
LOCALVARIABLE args [Ljava/lang/String; L0 L4 0
LOCALVARIABLE biteCode Lcom/jm/test/BiteCode; L1 L4 1
LOCALVARIABLE i Ljava/lang/Integer; L3 L4 2
MAXSTACK = 2
MAXLOCALS = 3
}
T
는 Object
로 컴파일되었다. 이렇게 컴파일되면 제네릭을 사용하지 않고, Object
를 사용하는 것과 어떤 차이가 있는 것일까? 핵심은 CHECKCAST
에 있다.
Integer i = biteCode.getT();
CHECKCAST java/lang/Integer
BiteCode
의 인스턴스에서 t
를 반환한 뒤, 해당 값이 Integer i
에 할당될 때, 컴파일러는 자동으로 getT
가 반환한 값이 Integer
인지 타입 확인을 수행한다. 즉, Heap영역에 존재할 때는 Object 타입으로 존재하지만, 실제로 해당 값이 사용될 때 타입 확인을 수행하는 것이다. 자바 컴파일러가 수행하는 타입 확인을 명시적으로 나타낸다면 다음과 같을 것이다.
BiteCode<Object> biteCode = new BiteCode<>();
biteCode.setT(10);
Integer i = (Integer)biteCode.getT();
제네릭은 Object를 형변환하고 타입을 확인하는 일련의 과정을 컴파일러에서 자동으로 추가해주는 기법이라고 볼 수 있다.
제네릭 사용법
제네릭에서 Type parameter
는 다음과 같은 convention을 따른다.
- 한 글자
- 대문자
대표적인 type parameter name은 다음과 같다.
- E - element
- K - key
- T - type
- V - value
- S,U,V etc. - 2nd, 3rd, 4th types
일반 변수의 name convention과 다르기 때문에 일반 변수와 type parameter를 쉽게 구분할 수 있게 함이다.
제네릭 type을 호출하고 초기화하는 법
BoxGeneric<Integer> boxGeneric = new BoxGeneric<>();
위 코드는 BoxGeneric 클래스의 인스턴스를 생성하기 위한 코드이다. T
가 위치하던 자리에 Integer
가 들어가있다. 이때 Integer
를 type argument
라고 하며, T
는 type parameter
라고 말한다.
The Diamond
자바 7부터 제네릭 클래스의 생성자를 호출하기 위해 사용했던 type argument
를 <>
로 대체할 수 있다. 컴파일러가 context를 확인하여 Type을 결정하게 된다.
Multiple Type Parameters
제네릭 클래스는 여러 개의 type parameter
를 받을 수 있다.
public class Pair<K, V>{
private K key;
private V value;
public Pair(K key, V value){
this.key = key;
this.value = value;
}
public K getKey(){return key;}
public V getValue(){return value;}
}
Pair<String, Integer> p1 = new Pair<String,Integer>("Even",8);
Pair<String, String> p2 = new Pair<String,String>("hello","world");
K, V는 서로 다른 타입일 수 있고, 같은 타입일 수 있다. type argument
는 primitive type
이여도, 자바의 autoboxing
덕분에 Wrapper class
로 변환되어 정상적으로 컴파일 된다.
- autoboxing
wrapper class
를parameter
로 받는 메소드에서 해당 wrapper class와대응되는 primitive type의 값을 넘겨줄 때wrapper class
로 변환할 수 있을 때
primitive type
의 값을 해당 타입과 대응되는wrapper class
로 변환해주는 것을autoboxing
이라고 말한다. autoboxing은 위 조건에서 수행된다.- unboxing
primitive type
을parameter
로 받는 메소드에서 해당 parameter type과 대응되는wrapper class
의 인스턴스를 넘겨줄 때primitive type
변 로 변환할 수 있을 때
autoboxing
과는 반대로,wrapper class
의 인스턴스를 대응되는primitive type
의 변수로 변환하는 것을unboxing
이라고 말한다. unboxing은 위 조건에서 수행된다.
제네릭 주요 개념 (바운디드 타입, 와일드 카드)
바운디드 타입
type parameter
에 들어갈 type argument
를 특정 타입으로 제한하고 싶을 수 있다. 예를 들어 Number
클래스의 인스턴스이거나, 서브클래스이도록 제한을 두고 싶을 수 있다. 바운디드 타입이 이러한 제한을 가능케 한다.
위와 같이 바운디드 타입 파라미터를 정의한다. U
는 upper bound인 Number
의 인스턴스 or 서브클래스 이어야 한다.
public class BoundedBox<T> {
private T t;
public void set(T t){
this.t = t;
}
public <U extends Number> void inspect(U u){
System.out.println("T: " + t.getClass().getName());
System.out.println("U: " + u.getClass().getName());
}
public static void main(String[] args) {
BoundedBox<Integer> boundedBox = new BoundedBox<>();
boundedBox.set(10);
boundedBox.inspect("hello!"); // 여기서 컴파일 에러 발생
}
}
//출력문(에러)
//java: method inspect in class com.jm.test.BoundedBox<T> cannot be applied to given types;
// required: U
// found: java.lang.String
// reason: inferred type does not conform to upper bound(s)
// inferred: java.lang.String
// upper bound(s): java.lang.Number
inspect
제네릭 메소드의 type parameter
는 Number
클래스를 upper bound로 삼고 있다. 그런데 inspect
를 호출하는 곳에서 type argument
로 String
타입의 값을 넘겨주었다. String
은 Number
클래스의 인스턴스 or 서브클래스가 아니기 때문에 type 할당이 불가능하다.
- 멀티 바운드
위에서는 하나의 바운드만 설정하였지만,type parameter
는 다중 바운드를 가지고 있을 수 있다. -
<T extends B1 & B2 & B3>
- 그런데 만약 멀티 바운드 중에 클래스가 존재한다면, 해당 클래스는 반드시 가장 맨 앞에 명시 되어야 한다.
-
Class A { /* ... */ } interface B { /* ... */ } interface C { /* ... */ } class D <T extends A & B & C>{} class D <T extends B & A & C>{} // compile-time error
- 제네릭 메소드와 바운디드 타입
public interface Comparable<T>{ public int compareTo(T o); } public static <T extends Comparable<T>> int countGreaterThan(T[] anArray, T elem){ int count = 0; for(T e : anArray) if(e > elem) // compiler error ++count; return count; }
- 위 함수는 정상적으로 동작할 것 같지만, 비교(>)부분에서 컴파일 에러가 발생한다. 비교 연산자는 오직 primitive type 에 대해서만 수행 가능하다. T 는 primitive type 이 아닐 수 있기 때문에 에러가 발생한다. 이 문제를 해결하기 위해
type parameter
가Compare<T>
인터페이스를 upper bound로 삼도록 해야 한다. -
public interface Comparable<T>{ public int compareTo(T o); } public static <T extends Comparable<T>> int countGreaterThan(T[] anArray, T elem){ int count = 0; for(T e : anArray) if(e > elem) // compiler error ++count; return count; }
제네릭, 상속, 그리고 서브타입
Integer
는 Number
의 서브 클래스이다. 즉, Integer
는 Number
를 상속한다. 따라서, Number type
의 변수에 Integer type
변수가 대입될 수 있다.
그렇다면, Box<Number>
의 변수에 Box<Integer>
변수가 대입될 수 있을까?
public class GenericMethodBounded {
public static void boxTest(Box<Number> n){}
public static void main(String[] args) {
Box<Integer> integerBox = new Box<>();
boxTest(integerBox); // compile-time error
}
}
class Box<T>{}
결론은 “대입될 수 없다”이다. Integer
가 Number
의 서브타입이라도, Box<Integer>
는 Box<Number>
의 서브타입이 아니다.
제네릭 타입 상속 방법
class Box<T>{}
class ChildBox<T> extends Box{} // 1
class ChildBox<T> extends Box<T>{} // 2
class ChildBox<E> extends Box<T>{} // 3 error
class ChildBox<T> extends Box<E>{} // 4 error
class ChildBox<E> extends Box<E>{}
class ChildBox<T,E> extends Box<T>{} // 5
- ChildBox는 Box를 상속 받지만, Box의
type parameter
인T
를 상속 받지 않는다.ChildBox<T>
에서T
는 ChildBox의 독립적인type parameter
이다. 단, ChildBox의 생성자에서 Box의 생성자를 반드시 호출해야 한다. 그래야만,Box<T>
의type argument
를 전달할 수 있기 때문이다. -
class ChildBox<T> extends Box{ public ChildBox(T t2) { super(123); // 부모클래스의 생성자 호출 this.t2 = t2; } }
- ChildBox는 Box를 상속 받으며, Box의
type parameter
인T
도 상속 받는다. 따라서,ChildBox<T>
의T
는Box<T>
의T
와 일치한다. 1과 마찬가지로 부모 클래스의 생성자를 호출하는 것은 동일하다. Box<T>
에T
가 어떤 타입인지 알 수 없다. 자식 클래스인ChildBox
에서 부모 클래스의type parameter
를 정의해줘야 한다.- 3과 동일한 이유로 에러가 발생한다.
- 정상적으로 동작한다. 자식 클래스에서 부모 클래스의
type parameter
의name
을 따라갈 필요는 없다. 부모 클래스에게type argument
만 제시해주면 된다. - 부모 클래스의
type parameter
와 자식 클래스의 고유한type parameter
를 함께 사용하려면,ChildBox<T, E>
와 같이 전부 명시해줘야 한다.
Type Inference
타입 추론은 자바 컴파일러가 type argument
를 context
를 보고 직접 결정하는 것을 말한다.
public class BoxDemo {
public static <U> void addBox(U u, java.util.List<Box<U>> boxes){
Box<U> box = new Box<>();
box.set(u);
boxes.add(box);
}
public static <U> void outputBoxes(java.util.List<Box<U>> boxes){
int counter = 0;
for(Box<U> box : boxes){
U boxContents = box.get();
System.out.println("Box #" + counter + " contains [" +
boxContents.toString() + "]");
counter++;
}
}
public static void main(String[] args) {
java.util.ArrayList<Box<Integer>> listOfIntegerBoxes =
new java.util.ArrayList<>();
BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(30), listOfIntegerBoxes);
BoxDemo.outputBoxes(listOfIntegerBoxes);
}
static class Box<T>{
private T t;
public void set(T t){
this.t = t;
}
public T get(){
return t;
}
}
}
// 출력
// Box #0 contains [10]
// Box #1 contains [20]
// Box #2 contains [30]
위 코드에서 제네릭 메소드인 addBox
와 outputBoxes
를 집중적으로 보겠다.
BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(30), listOfIntegerBoxes);
BoxDemo.outputBoxes(listOfIntegerBoxes);
두 메소드의 호출문이다. 이상한 점은 첫번째 줄에서는 <Integer>
를 명시해주었는데, 두번째 줄부터는 type argument
를 지정하지 않았는데도 정상적으로 동작한다는 점이다. 이게 가능한 이유는 자바 컴파일러가 type inference
를 수행하였기 때문이다. 입력받은 값을 보고 type argument
를 유추하는 것이다.
와일드 카드
제네릭 코드에서 와일드 카드라고 불리는 ?
는 알 수 없는 타입을 명시한다. 와일드 카드는 type argument
로는 사용될 수 없다.
public <? extends Number> void method(){} // compile-error
위와 같은 형태로 와일드 카드를 사용할 수 없다. 와일드 카드는 제네릭 타입 혹은 제네릭 메소드에서 type argument
를 대신할 수 없다.
그렇다면 와일드 카드는 언제 사용되며 왜 사용하는 것일까? 사실 Lower Bounded를 제외하고, Object를 사용하여 와일드 카드로 가능한 모든 것들을 구현할 수 있다. 하지만, 이는 제네릭을 사용하는 이유와 어긋나는 행동이다. 또한, 와일드 카드를 사용하면 다음과 같은 코드를 간단하게 만들 수 있다.
어떤 타입이든 들어갈 수 있는 List를 List에 넣는다고 가정하자. 와일드 카드로 나타내면 다음과 같다.
public static void main(String[] args) {
List<List<?>> listOfAnyList = new ArrayList<>();
listOfAnyList.add(new ArrayList<String>());
listOfAnyList.add(new ArrayList<Double>());
}
와일드 카드를 사용하지 않는 코드는 다음과 같다.
class ListOfAnyClassWithGeneric{
List<Object> list = new ArrayList<>();
public <T extends List> void addList(T t){
list.add(t);
}
public static void main(String[] args) {
ListOfAnyClassWithGeneric listOfAnyList2 = new ListOfAnyClassWithGeneric();
listOfAnyList2.addList(new ArrayList<String>());
listOfAnyList2.addList(new ArrayList<Double>());
}
}
제네릭 함수가 아닌 곳에서 제네릭 키워드를 사용하려면, 해당 클래스에서 제네릭 메소드를 정의해야 한다. 또한, Object
(raw type)을 사용하는 것은 지양해야 할 방법이다. 이와 같이, 제네릭 키워드로 구현할 수 있지만, 와일드 카드로 손쉽게 구현할 수 있는 경우가 있기 때문에 와일드 카드를 사용한다고 볼 수 있다.
- Upper Bounded Wildcard
List<? extends Number>
라고 와일드 카드를 사용할 수 있다.List
의type argument
로 들어올 수 있는 것은Number
의 인스턴스 이거나, 하위 클래스만 가능하다는 것을 의미한다. 따라서,List<Integer>
orList<Number>
가 가능하다.
만약,List<Number>
를 사용한다면,List<Integer>
는 타입 캐스팅이 불가능할 것이다. 왜냐하면,Integer
가Number
의 하위 클래스라는 것이,List<Integer>
가List<Number>
의 하위 클래스라는 것을 보장하지 않기 때문이다.
- Lower Bounded Wildcard
Lower Bounded는 Upper와는 반대의 개념을 갖는다.List<? super Number>
라고 명시할 경우,?
에 들어갈 수 있는 것은Number
의 인스턴스이거나, 부모 클래스(ex.Object
)일 경우만 가능하다. Lower Bounded는 와일드 카드에서만 가능한 특징이다.
- Unbounded Wildcard
Unbounded는 모든 클래스가 가능하다는 것을 의미한다.List<?>
와 같이 사용한다. Unbounded는Object
클래스의 메소드 혹은 제네릭 클래스에서type parameter
와 상관없는 메소드를 정의할 때 사용한다.
제네릭 메소드 만들기
제네릭 메소드는 type parameter
를 가지고 있는 메소드를 말한다. generic type
과 유사해 보이지만, type parameter
의 스코프가 메소드에 한정된다는 점에서 차이가 있다. 즉, 제네릭 메소드의 type parameter
는 독립적인 파라미터이다.
generic type
은generic class
와generic interface
를 의미한다.
제네릭 메소드에 대해 알기 위해 제네릭 타입과 비교하겠다.
- 제네릭 타입에서 제네릭 사용
class Study<T>{
static T t;
}
static 변수는 type parameter일 수 없다. 왜냐하면 static 변수는 클래스의 인스턴스가 생성되기 이전에 먼저 메모리에 적재되는데, T
가 어떤 type인지 알 수 없기 때문이다.
class Study<T>{
static T method(T t){
return t;
}
}
static 메소드의 경우도 static 변수와 동일한 이유로 사용이 불가능 하다.
- 제네릭 메소드를 사용하면 type parameter를 갖는 static 함수를 정의할 수 있다.
class Study<T>{
static <E> E method(E e){
return e;
}
}
이때 주의할 점은 제네릭 메소드의 type parameter는 제네릭 타입의 type parameter와 별개라는 것이다. 즉, 위 예제에서 T
와 E
는 서로 다른 type parameter이다. 제네릭 메소드는 자신만의 고유한 type parameter를 갖는다. 제네릭 타입에 종속적이지 않다는 특징 덕분에, static 하게 동작할 수 있다.
- 제네릭 메소드는 일반 클래스에서도 정의될 수 있다.
class Study{
static <E> E method(E e){
return e;
}
}
제네릭 메소드에서는 고유한 type parameter를 갖기 때문에, 제네릭 메소드가 정의된 클래스의 Type과는 관계없이 어느 곳에서나 정의될 수 있다.
- 제네릭 메소드를 호출하는 방법
java public class Study { public <T> void method(T t){ System.out.println(t); } public static void main(String[] args) { Study study = new Study(); study.method("Generic method call!"); } }
Erasure
제네릭은 컴파일 타임에 타이트한 타입 체킹을 수행하기 위해 등장하였다. 제네릭을 구현하기 위해 자바 컴파일러는 type erasure
를 수행한다. type erasure
는 다음과 같다.
- 제네릭에 있는
type parameter
를Object
또는upper bounded class
로 대체한다. 따라서 바이트코드에는 제네릭이 아닌 일반적인 클래스 코드들이 저장된다. - type safety가 보장되어야 하는 곳에
type cast
를 추가한다. - 제네릭 타입의 상속이 있는 곳에
bridge method
를 추가한다.
Erasure of Generic Types
public class Node<T>{
private T data;
private Node<T> next;
public Node(T data, Node<T> next){
this.data = data;
this.next = next;
}
public T getData(){return data;}
}
위와 같은 제네릭 타입에서 type parameter
들은 Object
또는 upper bounded class
로 대체된다. 이렇게 대체함으로서, 실제 객체가 생성될 때 type argument
로 들어올 수 있는 모든 타입들을 수용할 수 있게 한다.
public class Node{
private Object data;
private Node next;
public Node(Object data, Node next){
this.data = data;
this.next = next;
}
public Object getData(){return data;}
}
Bridge Methods
public class Node<T>{
public T data;
public Node(T data){this.data = data;}
public void setData(T data){this.data = data;}
public static void main(String[] args) {
MyNode mn = new MyNode(5);
Node n = mn;
n.setData("Hello");
Integer x = mn.data;
}
}
class MyNode extends Node<Integer>{
public MyNode(Integer data) {
super(data);
}
public void setData(Integer data){super.setData(data);}
}
//출력문
//Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
// at com.jm.test.MyNode.setData(Node.java:16)
// at com.jm.test.Node.main(Node.java:11)
위 코드는 런타임 에러(ClassCastException
)를 발생시킨다. 제네릭은 컴파일 타임에 타입 체킹을 수행하는데 어째서 런타임 에러가 발생하는 것일까?
public class Node{
public Object data;
public Node(Object data){this.data = data;}
public void setData(Object data){this.data = data;}
public static void main(String[] args) {
MyNode mn = new MyNode(5);
Node n = mn;
n.setData("Hello");
Integer x = mn.data;
}
}
class MyNode extends Node<Integer>{
public MyNode(Integer data) {
super(data);
}
public void setData(Integer data){super.setData(data);}
}
type erasure
가 수행된 이후의 코드이다. Node
의 setData(Object data)
와 MyNode
의 setData(Integer data)
는 서로 파라미터의 타입이 다르기 때문에 오버라이딩이 이뤄지지 않는다. 이 문제를 해결하기 위해 자바 컴파일러는 Bridge method
를 추가한다. Bridge method
는 다음과 같다.
public void setData(Object data){
setData((Integer) data);
}
이를 통해, 오버라이딩이 진행된다. main()
에서 n.setData("Hello")
는 MyNode.setData(Object data)
를 호출하고, 해당 메소드에서는 setData((Integer) data)
를 호출한다. 그런데, 이때 넘겨받은 값은 String
이기 때문에 Integer
로 타입 캐스팅 되지 않는다. 따라서, ClassCastException
이 발생하게 된다.
주의할점! 자바는 Dynamic method dispatch를 지원한다. 실행될 메소드를 결정지을 때 컴파일 타임이 아닌, 런타임에 수행된다. 즉, 부모 클래스 변수에서 자식 클래스 객체를 가리키고 있을 때, 오버라이딩된 메소드를 호출하면 부모 클래스의 메소드가 아닌 자식 클래스의 메소드가 호출된다.
'프로그래밍 언어 > 자바' 카테고리의 다른 글
[Java] String & StringBuilder & StringBuffer (0) | 2023.12.12 |
---|---|
[Java] 람다 (0) | 2023.12.04 |
[Java] I/O (2) | 2023.12.04 |
[Java] 애노테이션 (3) | 2023.12.04 |
[Java] 클래스로더 이해하기 (1) | 2023.12.01 |