상속을 고려한 설계와 문서화 = public/protected 메소드 중 final 이 아닌 것

=> 메서드를 재정의하면 어떤 일이 일어나는지(내부적으로 어떻게 사용하고 있는지)를 정리하여 문서화 하자

 

java.util.AbstractCollection

 

위 API 문서의 Implementation Requirement 는 메서드의 내부 동작 방식을 설명하는 곳이다. 메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다. 

주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다 => 선택적 동작.
더 정확 하게 말하면, 이 컬렉션 안에 ‘Object.equals(o, e)가 참인 원소’ e가 하나 이상 있다면 그 중 하나를 제거한다.
주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경 됐다면) true를 반환한다.

Implementation Requirements: 이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다.
주어진 원소를 찾으면 반복자의 remove 메서드를 사용해 컬렉션에서 제거 한다.
이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메서드를 구현하지 않았다면 UnsupportedOperationException 을 던지니 주의하자.

이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 주며, iterator 메서드로 얻은 반복자의 동작이 remove 메서드의 동작에 주는 영향도 설명하고 있다.

자바 11의 자바독에서 이 태그를 활성화하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements:"를 지정 해줘야 한다.

 

 

내부 메커니즘을 문서로 남기는 것뿐만 아니라,

효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태나 드물게는 protected 필드로 공개해야 할수도 있다.

java.util.AbstractList

fromlndex(이상)부터 tolndex(미만)까지의 모든 원소를 이 리스트에서 제거한다.
tolndex 이후의 원소들은 앞으로 index만큼씩 당겨진다.
이 호출로 리스트는 `tolndex - fromlndex`만큼 짧아진다.

이 리스트/부분리스트에 정의된 clear 연산이 이 메서드를 호출하는데, 리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다.

Implementation Requirements: 이 메서드는 fromlndex에서 시작하는 리스트 반복 자를 얻어 모든 원소를 제거할 때까지 ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다.

주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.

removeRange 메서드가 사용되지 않지만 제공된 이유는 하위 클래스에서 부분 리스트의 clear 메서드를 고성능으로 만들기 위해서이다. removeRange 메서드가 없을 때 하위 클래스에서 clear 메서드를 호출하면 제거할 원소 수의 제곱에 비례해 성능이 느려지거나, 부분리스트의 메커니즘을 밑바닥부터 새로 구현해야 했을 것이다.

언제 메서드를 protected로 둘지 일정하진 않다. 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 유일하다.(약 3개)

상속을 허용하는 클래스의 경우 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다. 왜냐하면 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의 한 메서드가 하위 클래스의 생성자보다 먼저 호출된다. 이때 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것이다. 

 

아래 예를 살펴보자.

package com.example.sypark9646.item19;

public class Parent {

    public Parent() {
        System.out.println("parent 생성자");
        overrideMe();
    }

    public void overrideMe() {
        System.out.println("parent");
    }
}
package com.example.sypark9646.item19;

import java.time.Instant;

public class Child extends Parent {

    private final Instant instant;

    public Child() {
        System.out.println("child 생성자");
        this.instant = Instant.now();
    }

    @Override
    public void overrideMe() {
        System.out.println("child");
        System.out.println(instant);
    }
}
package com.example.sypark9646.item19;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class OverrideTimeTest {

    @DisplayName("상속용 클래스의 생성자의 재정의 가능 메소드 호출 테스트")
    @Test
    void overrideTest() {
        Child child = new Child();
        child.overrideMe();
    }
}

위 테스트에서 볼 수 있듯이, parent 생성자를 호출하면서 overrideMe 호출 -> 이때, Child 생성자를 만드는 것이 목표였기 때문에(?) child의 overrideMe를 호출하는데, 아직 Child가 초기화 되기 전이므로 instant = null이 찍힌다는 것을 확인할 수 있다.

Parent 생성자 안에서 부르는 overrideMe가 parent override를 프린트 할 줄 알았지만,,, 아니었다

 

즉, 여기서는 final 필드의 상태가 두 가지이다!! (정상이라면 단 하나)

overrideMe에서 instant 객체의 메서드를 호출하려 한다면 상위 클래스의 생성자가 overrideMe 를 호출할 때 NullPointerException을 던지게 된다.

(println의 경우 null 도 괜찮기 때문에 넘어간 것..)

(단, private final static 메서드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다)

 

 

Cloneable과 Serializable 인터페이스 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다.

굳이 하고싶다면, clone과 readObject 메서드는 생성자와 비슷한 효과를 내므로(새로운 객체 생성) 재정의 가능 메서드를 호출하면 안된다.

readObject의 경우 하위 클래스의 상태가 미처 다 역직렬화되기 전에 재정의 한 메서드부터 호출하게 되며,

clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 올바른 상태로 수정하기 전에 재정의한 메서드를 호출한다.

(특히 clone이 잘못되면 복제본뿐 아니라 원본 객체에도 피해를 줄 수 있다)

 

 

마지막으로, Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 protected로 선언해 야 한다. private으로 선언한다면 하위 클래스에서 무시되기 때문이다. 이 역시 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나다.

 

상속을 쓰는 경우는 추상 클래스나 인터페이스의 골격 구현(아이템 20)할 때가 있고, 불변 클래스(아이템 17) 등에는 절대 사용하지 않도록 하자.
일반적인 구체 클래스일 때에는 상속용으로 설계하지 않았을 경우 상속을 금지하도록 하는 것이 좋다.

상속을 금지하는 방법은 두 가지가 있는데, 가장 쉬운 방법은 클래스를 final로 선언하는 방법이다.

두 번째는 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법이다.

 

 

결론적으로 상속은 매우 복잡하다.. 상속 대신 사용할 수 있는 방법은 인터페이스가 될 수 있고, 래퍼 클래스 패턴(아이템18) 도 가능하다.
그렇지만 만약 구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용 하기에 상당히 불편해진다. 이런 클래스라도 상속을 꼭 허용해야겠다면 클래스 내부에서 재정의 가능 메서드를 사용하지 않게 만들고, 이 사실을 문서로 남기도록 하자. 

 


클래스의 동작을 유지하면서 재정의 가능 메서드를 사용하는 코드를 제거할 수 있는 기계적인 방법은 아래와 같다.

먼저 각각의 재정의 가능 메서드는 자신의 본문 코드를 private `도우미 메서드`로 옮기고, 이 도우미 메서드를 호출하도록 수정한다.

그런 다음 재정의 가능 메서드를 호출하는 다른 코드들도 모두 이 도우미 메서드를 직접 호출하도록 수정하면 된다.

package com.example.sypark9646.item19;

public class Parent {

    public Parent() {
        System.out.println("parent 생성자");
        helpOverride();
    }

    public void overrideMe() {
        helpOverride();
    }

    private void helpOverride(){
        System.out.println("parent override");
    }
}
package com.example.sypark9646.item19;

import java.time.Instant;

public class Child extends Parent {

    private final Instant instant;

    public Child() {
        System.out.println("child 생성자");
        this.instant = Instant.now();
    }

    @Override
    public void overrideMe() {
        helpOverride();
    }

    private void helpOverride(){
        System.out.println("child override");
        System.out.println(instant);
    }
}

실행 결과를 보면, parent의 overrideMe(helpOverride)를 호출함으로써 instant = null 값이 출력되는 일이 발생하지 않았고,

하위 클래스에서 재정의한 메소드 또한 잘 수행됨을 알 수 있다.

item3 -
싱글턴을 만드는 방식
기본적으로 생성자는 private 으로 감춰두고, => 인스턴스 생성 불가, 서브클래스 생성 불가
유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버 를 하나 마련해둔다.

 

정적 메서드와 정적 필드만을 담은 클래스를 만드는 경우

  • 기본 타입 값이나 배열 관련 메서드들을 모아놓는 경우: java.lang.Math, java.util.Arrays
  • 특정 인터페이스를 구현하는 객체를 생성해주는 정적 메서드/팩터리 메소드들을 모아놓는 경우: java.util.Collections 
  • final 클래스와 관 련한 메서드들을 모아놓는 경우(final 클래스를 상속해서 하위 클래스에 메서드를 넣는 건 불가능)

생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자(매개변수 없는 public 기본 생성자)를 만들어준다.

그런데 읽다가 이런 이야기가 있었다.

추상 클래스로 만드는 것으로는 인스턴스화를 막을 수 없다.
아래와 같이 하위 클래스를 만들어 인스턴스화하면 그만이다.
이를 본 사용자는 상속해서 쓰라는 뜻으로 오해할 수 있으니 더 큰 문제다. (아이템 19)

 

무슨 말일까... 자바 공식 문서를 한번 보자

docs.oracle.com/javase/tutorial/java/IandI/abstract.html

 

Abstract Methods and Classes (The Java™ Tutorials > Learning the Java Language > Interfaces and Inheritanc

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Java Language Changes for a summary of updated

docs.oracle.com

 Abstract classes cannot be instantiated, but they can be subclassed.

여기서 나온 예제를 직접 한 번 구현해 보았다.

 

Shape는 추상 클래스로 두고, Circle과 Rectangle은 이를 extends 해서 구체화 하여 구현할 수 있다.

package com.example.sypark9646.item4;

public abstract class Shape {

	protected int x, y;

	public Shape() {
		System.out.println("Shape 호출");
	}

	public Shape(int x, int y) {
		this.x = x;
		this.y = y;
	}

	public abstract String getName();

	public void drawCenter() {
		System.out.println("x = " + x + ", y = " + y);
	}
}
package com.example.sypark9646.item4;

public class Circle extends Shape {

	int radius;

	public Circle() {
		// super(); 묵시적 호출
		System.out.println("Circle 호출");
	}

	public Circle(int x, int y, int radius) {
		super(x, y);
		this.radius = radius;
	}

	@Override
	public void drawCenter() {
		super.drawCenter();
		System.out.println("radius = " + radius);
	}

	@Override
	public String getName() {
		return "circle" + this.hashCode();
	}
}
package com.example.sypark9646.item4;

public class Rectangle extends Shape {

	int row, col;

	public Rectangle() {
		// super(); 묵시적 호출
		System.out.println("Rectangle 호출");
	}

	public Rectangle(int x, int y, int row, int col) {
		super(x, y);
		this.row = row;
		this.col = col;
	}

	@Override
	public void drawCenter() {
		super.drawCenter();
		System.out.println("row = " + row + ", col = " + col);
	}

	@Override
	public String getName() {
		return "rectangle" + this.hashCode();
	}
}

 

추상클래스의 경우 아래와 같이 그냥 Shape 그 자체를 인스턴스화 할 순 없지만,

Circle과 Rectangle은 생성자를 통해 인스턴스화가 가능하고, 이 생성자들을 부르게 되면 상위 클래스 Shape의 생성자를 호출하게 된다.

package com.example.sypark9646.item4;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class AbstractInstantiateTest {

	@Test
	@DisplayName("추상클래스 인스턴스화 테스트")
	public void testInstantiateShape() throws InterruptedException {
		Shape circle = new Circle();
		Shape rectangle = new Rectangle();

		circle.drawCenter();
		rectangle.drawCenter();
	}
}

위와 같이 Circle, Rectangle 인스턴스를 생성하기 위해서는 반드시 생성자를 호출해야 한다.

하지만 생성자는 상속되지 않고 멤버만 상속된다. 대신 자식 클래스로 인스턴스를 생성하게 되면 부모 클래스의 생성자를 super로 호출한다.

즉, 부모 클래스의 생성자가 호출되어야 자식 클래스를 인스턴스화할 수 있다.

 

 

 

그래서 어떤 클래스의 인스턴스화를 확실히 막는 방법은 private 기본 생성자를 추가하는 것이다.

위에서 제시한 java.lang.Math, java.util.Arrays, 그리고 java.util.Collections 또한 private 생성자로 인스턴스화를 막고 있는 것을 확인할 수 있었다.

public class Collections {
    // Suppresses default constructor, ensuring non-instantiability.
    private Collections() {}
}

public final class Math {

    /**
     * Don't let anyone instantiate this class.
     */
    private Math() {}
}

 

만약 유틸리티 클래스를 만들게 된다면 아래와 같이 만들면 된다.

public class Utilityclass {
    // 기본 생성자가 만들어지는 것을 막는다, 인스턴스화 방지용
    private Utilityclass() { throw new AssertionError(); }
}

생성자를 명시적으로 private이니 클래스 바깥에서는 접근하지 못하도록 하고,

꼭 Assertion Error를 던질 필요는 없지만, 클래스 안에서 실수로라도 생성자를 호출하지 않도록 해준다.

즉, 어떤 환경에서도 클래스가 인스턴스화되는 것을 막아 준다.

추가적으로 사용자가 이해하기 쉽도록 생성자가 존재하는데 호출할 수 없다는 내용을 주석으로 달아주면 더 좋다.

이 방식은 상속을 불가능하게 하는 효과도 있다. 

Shape 예제에서 볼 수 있듯이 모든 생성자는 명시적이든 묵시적이든 부모 클래스의 생성자를 호출하게 되는데, 

이를 private으로 선언 하게 되면 하위 클래스가 상위 클래스의 생성자에 접근할 길이 막혀버리기 때문에 컴파일 타임에 상속이 불가능하다는 것을 알 수 있다.

 

 

Abstract class vs Interface

인스턴스화를 막는 방법으로서 private 기본 생성자를 추가하는 예시를 자바 컬렉션에서 찾아보다가 궁금한 점이 생겼다

HashSet의 경우 AbstractSet 추상클래스를 상속하고 있는데, Set 인터페이스또한 implement하고 있다.

그런데 AbstractSet은 Set을 implement하고 있다.

그렇다면 HashSet에서는 Set을 이미 implement하고 있는 AbstractSet만 상속하여 구현하면 될텐데 왜 굳이 두가지를 모두 상속하고 구현한 것일까?

 

public class HashSet<E> extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable{...}

public abstract class AbstractSet<E> extends AbstractCollection<E> implements Set<E> {...}

public interface Set<E> extends Collection<E> {...}

 

 

Abstract class가 Interface를 implement하는 이유는 무엇이고 어떻게 동작하는 것일까?

이와 관련된 질문을 스택오버플로우에서 찾을 수 있었다.

출처: https://stackoverflow.com/questions/49757423/what-happens-when-an-abstract-class-implements-an-interface-in-java
What exactly happens when an abstract class implements an interface? Does this work like inheritance, i.e. all interface methods belong also to the abtract class eventhough they are not implemented in it? Or will only the implemented methods belong to the abstract class? So is there any difference between implements and extends, except that one is used to implement interfaces and the other one is used for inheritance?

이에 대한 답변은 정리하자면 아래와 같다.

if you have an abstract class and implement an interface with it, you have two options for the interface methods.

예를 들어 소리를 낼 수 있는지 여부를 나타내는 인터페이스 CanMakeNoise, 동물을 나타내는 추상클래스 Animal이 있다고 하자

package com.example.sypark9646.item4;

public interface CanMakeNoise {

	void makeNoise();
}

 

1. implement them in the abstract class

package com.example.sypark9646.item4;

public abstract class Animal implements CanMakeNoise {

	public abstract void jump();

	@Override
	public void makeNoise() { // interface 함수 구현
		System.out.println("animal noise");
	}
}

abstract class에 interface에서 정의한 함수를 구현하면 구체클래스에서는 이를 재 정의할 필요 없다

package com.example.sypark9646.item4;

public class Dog extends Animal implements CanMakeNoise{

	@Override
	public void jump() {
		System.out.println("dog jumps");
	}

//	@Override
//	public void makeNoise() {
//		System.out.println("dog noise");
//	}
}

 

물론 makeNoise 함수를 Override하여 구현 해줘도 되긴 하다. 이럴 경우 메소드가 오버라이드 되어서 "dog noise"가 나온다.

 

2. you leave them abstract, but then some of your more concrete children need to implement it.

만약 abstract class에서 interface 함수를 구현하지 않고 implement만 한다면

package com.example.sypark9646.item4;

public abstract class Animal implements CanMakeNoise {

	public abstract void jump();
}

구체 클래스 Dog에서는 두 abstract 함수를 필수적으로 모두 구현해 주어야 한다.

package com.example.sypark9646.item4;

public class Dog extends Animal implements CanMakeNoise {

	@Override
	public void jump() {
		System.out.println("dog jumps");
	}

	@Override
	public void makeNoise() {
		System.out.println("dog noise");
	}
}

 

이 예제의 경우는 Animal은 특정 동물이 어떻게 makeNoise 하는지 알 수 없기 때문에 함수의 구현을 구체 클래스로 남겨 두는 것이 좋다.(2번 방법)

 

 

 

이와 반대로, 인터페이스의 중복된 구현을 추상클래스로 빼서 중복을 방지하는 디자인 패턴이 있을 수 있다.(1번 방법)

 

출처: effectiveprogramming.tistory.com/entry/interface-abstract-class-concrete-class-%ED%8C%A8%ED%84%B4
 

interface -abstract class - concrete class 패턴(인터페이스 구현 중복 해결 패턴)

interface - abstract class - concrete class 패턴은 인터페이스 구현 시 자주 발생하게 되는 중복 구현을 방지하는 패턴이다. 해결하고자 하는 문제 - 구현해야 할 클래스에 대한 인터페이스가 이미 정해진

effectiveprogramming.tistory.com

 

 

딴길로 좀 샌거같은데..

자바 컬렉션에서는 Abstract class가 Interface를 implement하는 이유는 정리해 보자면

클래스가 실제로 해당 인터페이스를 구현한다는 것을 기억하기 위해서라고 한다. 즉, 주어진 클래스의 전체 계층 구조를 거치지 않고 코드를 이해하는 데 도움이 될 수 있으며 문서화 할 때 가독성이 좋기 때문이다.

 

또한 이펙티브 자바에서는 인터페이스와 함께 사용할 abstract skeletal 구현 클래스를 추가하여 인터페이스와 추상 클래스의 장점을 결합 할 수 있다고 했다.

인터페이스는 유형을 정의하여 기본 메서드를 제공하는 반면, skeletal 클래스는 기본 인터페이스 메서드 위에 남아있는 기본이 아닌 인터페이스 메서드를 구현한다.

skeletal 구현을 확장하면 인터페이스 구현에서 대부분의 작업이 필요한데, 이것이 템플릿 방법 패턴이다.

관례에 따라 skeletal 구현 클래스는 AbstractInterface라고 하고, 여기서 Interface는 구현하는 인터페이스의 이름이다.

예로는 아래의 추상 클래스들이 있다.

AbstractCollection
AbstractSet
AbstractList
AbstractMap

 

 

인터페이스를 명시적으로 구현하는 것과 상속으로 구현하는 것은 분명 다르긴 하다.

extends AbstractSet, implements Set이라고 되어있지만, 아래와 같이 리플렉션을 통해 보면...

소스에 작성된 순서대로 HashSet에 의해 명시적으로 구현 된 인터페이스만 표시한다는 것을 알 수 있다.

 

for (Class<?> c : ArrayList.class.getInterfaces())
    System.out.println(c);

// interface java.util.List
// interface java.util.RandomAccess
// interface java.lang.Cloneable
// interface java.io.Serializable

ArrayList 또한 마찬가지이다. 

출력에는 super class에 의해 구현 된 인터페이스 또는 포함 된 super interface인 인터페이스가 포함되지 않는다.

public interface List<E> extends Collection<E> {
	...
}
public interface Collection<E> extends Iterable<E> {
	...
}

특히, ArrayList가 암시적으로 구현하더라도 Iterable과 Collection은 위에서 누락되었다는 것을 알 수 있다.

Collection과 Iterable을 찾으려면 클래스 계층 구조를 재귀 적으로 반복해야한다.

그렇지만 다행히 이 차이는 `new ArrayList <> () instanceof Iterable` 및 `Iterable.class.isAssignableFrom (ArrayList.class)`은 올바르게 ​​true로 나온다.

 

 

마지막으로 추상 클래스와 인터페이스의 특징에 대해 각각 알아보자면,

  • 추상클래스가 인터페이스보다 빠르다 (그렇지만 별 차이 없는 정도이다)
  • 인터페이스는 다중 상속이 가능하지만, 추상클래스는 최대 1개만 가능하다.
  • 추상클래스는 모든 추상메소드를 재구현(Override)해야하지만, 인터페이스는 필요한 것만 구현해도 된다.
  • 인터페이스에는 접근 제어자가 없다. 인터페이스 내부에서 선언된 모든 것들은 public만 가능하다. 반면 추상클래스는 접근제어자가 가능하다.
  • 인터페이스의 경우 다양한 하위 구현 클래스들이 같은 메소드 특징을 공유할 때 사용한다. 추상클래스는 주로 동일한 종류의 다양한 구현이 공통 동작을 공유 할 때 사용한다.
  • 인터페이스는 데이터 필드를 가질 수 없지만 추상 클래스는 가질 수 있다.
  • 인터페이스는 생성자가 없으며, 추상클래스는 생성자가 있다.
  • 인터페이스의 경우 java8부터 default 메소드를 통해 메소드 내부 구현이 가능하다.

 

+ Recent posts