Chapter 2
Item 1 생성자 대신 정적 팩터리 메서드를 고려하라
정적 팩터리 메서드가 생성자 보다 좋은 장점 다섯가지.
- 이름을 가질 수 있다.
정적 팩터리는 이름만 잘 지으면 반환될 객체의 특성을 쉽게 묘사할 수 있다.
// 생성자
BigInteger(int, int, Random)
//정적 팩터리 메서드
BigInteger.probablePrime
- 호출될 때마다 인스턴스를 새로 생성하지는 않아도 된다.
이는 인스터스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불피요한 객체 생성을 피할 수 있다. 대표적인 예인 Boolean.valueOf(boolean) 메서드는 객체를 아예 생성하지 않는다. (?)
반복되는 요청에 같은 객체를 반환하는 식으로 정적 팩터리 방식의 클래스는 언제 어느 인스턴스를 살아 있게 할지 철저히 통제할 수 있다. 이런 클래스를 인스턴스 통제 클래스라 한다. 인스턴스를 통제하면 클래스를 싱글턴으로 만들 수도, 인스턴스화 불가로 만들 수 도 있다. 또한 불변 값 클래스 에서 동치인 인스턴스가 단 하나뿐임을 보장할 수 있다.
- 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.
API를 만들 떄 이 유연성을 응용하면 구현 클래스를 공개하지 않고도 그 객체를 반환할 수 있어 API를 작게 유지할 수 있다.
- 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체를 반환하든 상관없다.
- 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
이런 유연함은 서비스 제공자 프레임워크를 만드는 근간이 된다.
정적 팩터리 메서드의 단점.
- 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
- 정적 팩터리 메서드는 프로그래머가 찾기 어렵다.
Item 2 생성에 대개변수가 많다면 빌더를 고려하라
생성자가 많은 경우 각 생성자를 할당하는 모든 경우의 수를 작성해야한다.
점층적 생성자 패턴도 쓸 수는 있지만, 매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다. 코드를 읽을 때 각 값의 의미가 무엇인지 햇갈릴 것이고, 배개변수가 몇 개인지도 주의해서 세어 보아야 할 것이다. 클라이언트가 실수로 매개변수의 순서를 바꿔도 동작하는 경우가 있다.
다른 패턴인 자바빈즈 패턴에서는 객체 하나를 만들려면 메서드를 여러 개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 사태에 놓이게 된다. 점층적 생성자 패턴에서는 매개변수들이 유효한지를 생성자에서만 확인하면 일관성을 유지할 수 있었는데, 그 장치가 완전히 사라진 것이다. 이러한 문제 때문에 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없으며 스레드 안전성을 얻으려면 프로그래머가 추가 작업을 해줘야만 한다.
빌더 패턴을 사용하면 클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻는다. 그런 다음 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정한다. 마지막으로 매개변수가 없는 build 메서드를 호출해 드디어 우리에게 필요한 객체를 얻는다.
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
//필수 매개변수
private final int servingSize;
private final int servings;
//선택 매개변수 - 기본값으로 초기화한다
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder fat(int val) {
fat = val;
return this;
}
public Builder sodium(int val) {
sodium = val;
return this;
}
}
private NutritionFacts (Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
빌더의 세터 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출 할 수 잇다. 이런 방식을 메서드 호출이 흐르듯 연결된다는 뜻으로 플루언트 API 혹은 메서드 연쇄라 한다.
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100).sodium(35).carbohudrate(27).build();
빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다. 각 계층의 클래스에 관련 빌더를 멤버로 정의하자. 추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖게 한다.
public abstract class Pizza {
public enum Topping {HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
final Set<Topping> toppings;
abstract static class Builder<T extends Builder<T>> {
EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
public T addTopping(Topping topping) {
toppings.add(Objects.requireNonNullElse(topping));
return self();
}
abstract Pizza build();
protected abstract T self();
}
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone();
}
}
자 위의 Pizza 객체를 상속받아 객체를 만들고, 이를 할당하는 부분의 코드는 아래와 같다.
public class NyPizza extends Pizza {
public enum Size {SMALL, MEDIUM, LARGE};
private final Size size;
public static class Builder extends Pizza.Builder<Builder> {
private final Size size;
public Builder(Size size) {
this.size = Objects.requireNonNull(size);
}
@Override public NyPizza build() {
return new NyPizza(this);
}
@Override protected Builder self() {return this;}
}
private NyPizza(Builder builder) {
super(builder);
size = builder.size;
}
}
NyPizza pizza = new NyPizza.Builder(SMALL).addTopping(ONION).build();
각 하위 클래스의 빌더가 정의한 build 메서드는 해당하는 구체 하위 클래스를 반환한다. 하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환타입이 아닌, 그 하위 타입을 반환하는 기능을 공변 변환 타이핑이라한다.
빌더를 적절한 메서드로 나눠 선언하면 연쇄적으로 객체를 생성할 수 있고 유지보수가 매우!!!!! 쉬울 것 같다.
item3 private 생성자나 열거 타입으로 싱글턴임을 보증하라
싱글턴이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다. 싱글턴의 전형적인 예로는 함수와 같은 무상태 객체나 설계상 유일해야 하는 시스템 컴포넌트를 들 수 있다. 그런데 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다.
싱글턴을 만드는 방식은 보통 둘 중 하나다. 두 방식 모두 생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 하나 마련해둔다. 우선 public static 멤버가 final 필드인 방식을 살펴보자.
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
}
private 생성자는 public static final 필드인 Elvis.INSTANCE를 초기화할 때 딱 한 번만 호출된다. public이나 protected 생성자가 없으므로 Elvis클래스가 초기화될 때 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장된다.
싱글턴을 만드는 두 번째 방법에서는 정적 팩터리 메서드를 public static 멤버로 제공한다.
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() {}
public static Elvis getInstance() { return INSTANCE; }
}
둘 중 하나의 방식으로 만든 싱글턴 클래스를 직렬화하려면 단순이 Serializable을 구현한다고 선언하는 것만으로는 부족하다. 모든 인스턴스 필드를 일시적이라고 선언하고 readResoolve 메서드를 제공해야 한다.
private Object readResolved() {
return INSTANCE;
}
싱글턴을 만드는 세 번째 방법은 원소가 하나인 열거 타입을 선언하는 것이다.
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() {}
}
public 필드 방식과 비슷하지만, 더 간결하고, 추가 노력 없이 직렬화할 수 있고, 심지어 아주 복잡한 직렬화 상황이나 리플렉션 공격에서도 제2의 인스턴스가 생기는 일을 완벽히 막아준다. 조금 부자연스러워 보일 수는 있으나 대부분상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다.
item4 인스턴스화를 막으려거든 private 생성자를 사용하라
이따금 단순히 정적 메서드와 정적 필드만을 담은 클래스를 만들고 싶을 때가 있다. java.lang.Math나 java.util.Arrays처럼 기본 타입 값이나 배열 관련 메서드들을 모아놓을 수 있다. 특정 인터페이스를 구현하는 객체를 생성해주는 정적 메서드를 모아놓을 수도 있다. 마지막으로 final 클래스와 관련한 메서들을 모아놓을 때도 사용한다.
정적 멤버만 담은 유틸리티 클래스는 인스턴스로 만들어 쓰려고 설계한 게 아니다. 하지만 생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어준다.
추상 클래스로 만드는 것으로는 인스턴스화를 막을 수 없다. 하위 클래스를 만들어 인스턴스화하면 그만이다. 다행이도 인스턴스화를 막는 방법은 아주 간단하다. 컴파일러가 기본 생성자를 만드는 경우는 오직 명시된 생성자가 없을 때뿐이니 private 생성자를 추가하면 클래스의 인스턴스화를 막을 수 있다.
public class UtilityClass {
private UtilityClass() {
throw new AssertionError();
}
}
명시적 생성자가 private이니 클래스 바깥에서는 접근할 수 없다. 꼭 Assertion Error를 던질 필요는 없지만, 클래스 안에서 실수라도 생성자를 호출하지 않도록 해준다. 이 코드는 어떤 환경에서도 클래스가 인스턴스화되는 것을 막아준다. 그런데 생성자가 분명 존재하는데 호출할 수 없다는 개념자체가 매우 모호하다... 적절한 주식을 달아보자!
위의 코드는 상속을 불가능하게 하는 효과도 있다. 모든 생성자는 명시적이든 묵시적이든 상위 클래스의 생성자를 호출하게 되는데, 이를 private으로 선언 했으니 하위 클래스가 상위 클래스의 생성자에 접근할 길이 막혀버린다.
item5 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
사용하는 자원에 따라 동작이 달라지는 클래스에는 정적 유틸리티 클래스나 싱글턴 방식이 적합하지 않다.
인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식을 사용한다면 클래스가 여러 자원 인스턴스를 지원해야 하며, 클라이언트가 원하는 자원을 사용할 수 있다.
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexion dictionary) {
this.dictionary = Objects.requireNonNull(dictionary);
}
}
위의 패턴의 쓸만한 변형으로, 생성자에 자원 팩터리를 넘겨주는 방식이 있다. 팩터리란 호출할 때마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체를 말한다.
item6 불필요한 객체 생성을 피하라
똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을 때가 많다. 아래의 두개의 코드를 비교해보자.
String s1 = new String("bikini");
String s2 = "bikini";
위의 구문은 실행될 때 마다 String 인스턴스를 새로 만들게 된다. 아래의 구문은 하나의 String 인스턴스를 사용한다. 나아가 이 방식을 사용한다면 같은 가상 머신 안에서 이와 똑같은 문자열 리터럴을 사용하는 모든 코드가 같은 객체를 재사용함이 보장된다.
생성자 대신 정적 팩터리 메서드를 제공하는 불변 클래스에서는 정적 팩터리 메서드를 사용해 불필요한 객체 생성을 피할 수 있다.
모르고 있었던 사실의 코드 (ㄷㄷ)
String.matches는 정규표현식으로 문자열 형태를 확인하는 가장 쉬운 방법이지만, 성능이 중요한 상황에서 반복해서 사용하기엔 적합하지 않다. 내부에 Pattern 인스턴스는 한 번 쓰고 버려저 곧바로 가비지 컬렉션 대상이 된다. (이 부분이 궁금해서 matches 메서드를 확인해봤다...)
/**
* Tells whether or not this string matches the given <a
* href="../util/regex/Pattern.html#sum">regular expression</a>.
*
* <p> An invocation of this method of the form
* <i>str</i>{@code .matches(}<i>regex</i>{@code )} yields exactly the
* same result as the expression
*
* <blockquote>
* {@link java.util.regex.Pattern}.{@link java.util.regex.Pattern#matches(String,CharSequence)
* matches(<i>regex</i>, <i>str</i>)}
* </blockquote>
*
* @param regex
* the regular expression to which this string is to be matched
*
* @return {@code true} if, and only if, this string matches the
* given regular expression
*
* @throws PatternSyntaxException
* if the regular expression's syntax is invalid
*
* @see java.util.regex.Pattern
*
* @since 1.4
* @spec JSR-51
*/
public boolean matches(String regex) {
return Pattern.matches(regex, this);
}
Pattern 메서드의 matches를 사용하는데... 이 Pattern 객체가 가비지 컬랙션의 대상이 되는 것 같다.
public class RomanNumerals {
private static final Pattern ROMAN = Pattern.compile("");
static boolean isRomanNumeral(String s) {
return ROMAN.matcher(s).matches();
}
}
위처럼 코드를 작성하면 Pattern객체를 final로 받기에 한번만 생성하고 앞으로 쭉 사용할 수 있다. 코드가 (6.5배 빨라진다 ㄷㄷ)
private static long sum() {
Long sum = 0L;
for(long i = 0; i <= Integer.MAX_VALUE; i++) {
sum += i;
}
return sum;
}
위의 코드는 sum 변수를 long이 아닌 Long으로 선언해서 불필요한 Long 인스턴스가 약 2^31개나 만들어진 것 이다. 박싱된 기본 타입 보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의하자.
item7 다 쓴 객체 참조를 해제하라
가비지 컬렉터를 갖춘 언어는 자칫 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있는데, 이는 사실이 아니다. 다음 스택 코드를 보자.
public class Stack {
private Objects[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Objects[DEFAULT_INITIAL_CAPACITY];
}
public void push(Objects e) {
ensureCapacity();
elements[size++] = e;
}
public Objects pop() {
if(size == 0)
throw new EmptyStackException();
return elements[--size];
}
private void ensureCapacity() {
if(elements.length == size) {
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
}
위의 코드에서 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다. 프로그램에서 해당 객체들을 더 이상 사용하지 않더라도 말이다. 이 스택이 그 객체들의 다 쓴 참조를 여전히 가지고 있기 때문이다.
가비지 컬렉션 언어에서는 메모리 누수를 찾기가 아주 까다롭다. 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다. 그래서 단 몇개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다.
해법은 해당 참조를 다 썼을 때 null처리를 진행하면 된다.
public Objects pop() {
if(size == 0)
throw new EmptyStackException();
Objects result = elements[--size];
elements[size] = null;
return result;
}
일반 적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다. 원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해줘야 한다.
item8 finalizer와 cleaner 사용을 피하라
- finalizer, cleaner란 무엇인가...
이 둘은 자바에서 제공하는 객체 소멸자이다. 그중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다. finalizer는 나름의 쓰임새가 몇 가지 있긴 하지만 기본적으로 쓰지 말아야 한다. 그래서 자바 9에서는 finalizer를 사용 자제 api로 지정하고 cleaner를 그 대안으로 소개했다. cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다.
- finalizer와 cleaner는 즉시 수행된다는 보장이 없다.
객체에 접근이 끊어진 후 이 두 소멸자가 실행되기까지 얼마나 걸릴지 알 수 없다. 즉, finalizer와 cleaner로는 제때 실행되어야 하는 작업으 절대 할 수 없다.
해당 소멸자를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달려있으며 이는 가비지 컬렉터 구현마다 천차만별이다.
- finalizer는 자원 회수를 지연시킨다.
어플리케이션이 죽는 시점에 크래픽스 객체 수천 개가 finalizer 대기열에서 회수되기만을 기다리는 경우 OutOfMemoryError를 발생시키며 어플리케이션이종료된다. 이는 finalizer 스레드의 우선순위가 다른 어플리케이션 스레드의 우선순위보다 낮아서 실행될 기횔를 제대로 얻지 못해 발생한 문제이다.
- 수행 여부가 보장되지 않는다.
자바 언어 명세는 finalizer나 cleaner의 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다. 접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수 있다는 얘기다. 따라서 프로그램 생애주기와 상관 없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안 된다.
- finalizer와 cleaner는 심각한 성능 문제를 동반한다.
AutoCloseable 객체를 생성하고 가비지 컬렉커가 수거하기까지 12ns가 걸린반면, finalizer를 사용하면 550ns가 걸린다. 즉 finalizer를 사용한 객체를 생성하고 파괴하니 50배나 느렸다. finalizer가 가비지 컬렉터의 효율을 떨어뜨리기 때문이다. cleaner도 클래스의 모든 인스턴스를 수거하는 형태로 사용하면 성능은 finalizer와 비슷하다.
- finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수도 있다.
finalizer 공격원리는 간단하다. 생성자나 직렬화 과정에서 예외가 발생하면, 이 생성 되다 만 객체에서 악의적인 하위 클래스의 finalizer가 수행될 수 있게 된다. (?) 이렇게 일그러진 객체가 만들어지고 나면, 이 객체의 메서드를 호출해 애초에는 허용되지 않았을 작업을 수행하는 건 일도 아니다. 객체 생성을 막으려면 생성자에서 예외를 던지는 것만으로 충분하지만, finalizer가 있다면 그렇지도 않다.
- finalizer와 cleaner의 사용처 두 가지.
자원의 소유자가 close 메서드를 호출하지 않는 것에 대비한 안전망으로 사용할 수 있다. cleaner나 finalizer가 즉시 호출되리라는 보장은 없지만, 클라이언트가 하지 않는 자원 회수를 늦게라도 해주는 것이 아예 안 하는 것보다는 좋다.
네이티브 피어와 연결된 객체에서의 활용도가 높다. 네이비트 피어란 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다. 네이티브 피어는 자바 객체가 아니니 가비지 컬렉터는 그 존재를 알지 못한다. 그 결과 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못한다. cleaner나 finalizer가 나서서 처리하기에 적당한 작업이다.
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
private static class State implements Runnable {
int numJunkPiles;
State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}
@Override
public void run() {
System.out.println("clean room");
numJunkPiles = 0;
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}
@Override
public void close() throws Exception {
}
}
static으로 선언된 중첩 클래스인 State는 cleaner가 방을 청소할 때 수거할 자원들을 담고 있다. 이 예에서는 단순히 방 안의 쓰레기 수를 뜻하는 numJunkPiles 필드가 수거할 자원에 해당한다. 더 현실적으로 만들려면 이 필드는 네이티브 피어를 가리키는 포인터를 담은 final long 변수여야 한다. State는 Runnalbe을 구현하고, 그 안의 run 메서드는 cleanable에 의해 딱 한 번만 호출 될 것이다.
item9 try-finally 보다는 try-with-resources를 사용하라
자바 라이브러리에는 close 메서드를 호출해 직접 닫아줘야 하는 자원이 많다. IputStream, OutputStream, java.sql.Connection등이 좋은 예다. 자원 닫기는 클라이어트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어지기도 한다.
전통적으로 자원이 제대로 닫힘을 보장하는 수단으로 try-finally가 쓰였다. 예외가 발생하거나 메서드에서 반환되는 경우를 포함해서 말이다.
public class TEST {
static String firstLineOfFile(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
br.close();
}
}
}
위의 코드에서 기기에 물리적인 문제가 생긴다면 firstLineOfFile 메서드 안의 readLine 메서드가 예외를 던지고, 같은 이유로 close 메서드도 실패할 것이다. 이런 상황이라면 두 번째 예외가 첫 번째 예외를 완전히 집어삼켜 버린다. 그러면 스택 추적 내역에 첫 번째 예외에 관한 정보는 남지 않게 되어, 실제 시스템에서의 디버깅을 몹시 어렵게 한다.
위의 문제는 try-with-resources 덕에 모두 해결되었다. 이 구조를 사용하려면 해당 자원이 AutoCloseable인터페이스를 구현해야한다.
static String firstLineOfFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(path))){
return br.readLine();
}
}
try-with-resource 구문은 짧고 읽기 수월할 뿐 아니라 문제를 진단하기에도 훨씬 좋다.
try-with-resource에서도 catch 절을 쓸 수 있다. catch절 덕분에 try 문을 더 중첩하지 않고도 다수의 예외를 처리할 수 있다. 다음 코드에서는 firstLineOfFile 메서드를 살짝 수정하여 파일을 열거나 데이터를 읽지 못했을 때 예외를 던지는 대신 기본값을 반환하도록 해봤다.
static String firstLineOfFile(String path, String defaultVal) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(path))){
return br.readLine();
}catch (IOException e) {
return defaultVal;
}
}
'programming > Effective Java' 카테고리의 다른 글
[Effective Java] Chapter 4 (2) | 2025.02.01 |
---|---|
[Effective Java] Chapter 3 (1) | 2025.01.25 |