자바의 정석

SUADI·2022년 6월 13일

7장 - 객체지향 프로그래밍

1.1 상속

  • 상속이란 기존 클래스를 재사용하여 새로운 클래스를 작성하는 것이다. 공통적인 코드를 중복해서 사용하게 되면 코드를 변경할 일이 생길 경우 유지보수가 어려워지고 일관성을 유지하기 어렵기 때문에 상속이라는 개념을 사용하게 되었다.

  • 자식 클래스는 부모 클래스의 멤버를 상속받게 되며 자식 클래스의 멤버가 같거나 더 많다. 그래서 상속을 다른 의미로 하면 확장(extends)한다고도 볼 수 있다. 멤버의 개수를 늘리는 행위이기 때문이다.

  • 부모 클래스의 생성자와 초기화 블럭은 상속되지 않는다. 하지만 자식 클래스의 생성자를 만들 때 부모 클래스의 생성자를 호출해야만 자식 클래스의 인스턴스를 생성할 수 있다. 부모 클래스의 생성자를 자식 클래스의 생성자에서 호출해야만 하는 이유는 자손 클래스의 멤버가 부모 클래스의 멤버를 사용할 수도 있으므로 부모 클래스의 멤버들의 초기화가 되어있어야 하기 때문이다. 부모 클래스의 생성자를 호출하는 방법은 super()를 사용하며 의도적으로 부모 클래스의 생성자를 호출하지 않는 경우 컴파일러가 자동적으로 super();를 추가하기 때문에 문법에 맞지 않는 경우도 생기므로 문법에 맞게 부모 클래스의 생성자를 작성해야 한다.

  • 상속 이외에도 클래스를 재사용하는 방법이 있다. 클래스 내에 재사용 하고자 하는 클래스의 인스턴스를 생성하는 것이다. 즉, 확장하고자 하는 클래스 내에 필요로 하는 클래스의 인스턴스를 가르키는 참조변수를 멤버변수로 선언하는 것이다. 아래의 코드는 좌표를 나타내는 Point 클래스와 원을 나타내는 Circle 클래스이다. 상속을 통해 Point 클래스의 멤버변수를 사용할 수도 있지만 Circle 클래스 내에 멤버변수로 Point 클래스의 인스턴스 주소값을 담은 참조변수를 선언하면 상속 없이도 Point 클래스의 멤버변수를 재사용할 수 있다.

class Point {
    int x;
    int y;
}

class Circle {
    Point p = new Point;
    int r;
}

1.3 클래스 간의 관계

  • 클래스를 재사용할 수 있는 두가지 방법을 알아보았다. 하나는 상속, 하나는 멤버변수로 참조변수 선언. 두가지 방법은 각각 클래스 간의 관계가 다르게 나타난다. 상속의 경우 IS-A 관계, 즉 '~는 ~이다.' 예를 들면 '사자는 동물이다.'와 같은 클래스의 관계가 형성될 때 상속을 통해 관계를 맺는다. 멤버변수로 참조변수를 선언하는 경우엔 HAS-A 관계, 즉 '~는 ~를 가지고 있다.' 예를 들면 '원은 점을 가지고 있다.'와 같은 클래스의 관계가 형성 될 떄 멤버변수로 참조변수를 선언한다.
package sample;

public class Card {
    static final int KIND = 4;
    static final int NUM = 13;

    static final int SPADE = 4;
    static final int DIAMOND = 3;
    static final int HEART = 2;
    static final int CLOVER = 1;

    int kind;
    int number;

    Card() {
        this(SPADE, 1);
    }

    Card(int kind, int number) {
        this.kind = kind;
        this.number = number;
    }

    public String toString() {
        String[] kind = {"","CLOVER","HEART","DIAMOND","SPADE"};
        String numbers = "0123456789XJQK";

        return "kind : " + kind[this.kind] + ", number : " + numbers.charAt(this.number);
    }
}
  • 위 코드는 Card 클래스이고 카드를 카드 낱장을 정의하기 위해 만든 클래스이다. 생성자를 통해 모양과 숫자를 입력 받을 수 있고 인스턴스를 출력하면 toString() 메소드를 통해 종류와 숫자를 출력해준다.

  • 멤버 변수인 상수들을 static으로 선언한 이유는 Card 클래스와 Deck 클래스 간에 HAS-A 관계를 맺어 주어 Card 클래스의 멤버변수들을 Deck 클래스에서 객체 생성없이 사용하기 위해서 이다.

  • kind와 number는 생성자의 매개변수로 초기화될 멤버변수들이라 Deck 클래스에서 사용될 필요는 없으므로 static으로 선언하지 않아도 된다.

package sample;

public class Deck {
    final int CARD_NUM = 52;
    Card[] cardArr = new Card[CARD_NUM];

    Deck() { // 카드 초기화
        int i = 0;
        for (int k = Card.KIND; k > 0; k--) {
            for (int n = 0; n < Card.NUM; n++) {
                cardArr[i++] = new Card(k, n + 1);
            }
        }
    }

    Card pick(int index) {
        return cardArr[index];
    }

    Card pick() {
        int index = (int) (Math.random() * CARD_NUM);
        return cardArr[index];
    }

    void shuffle() {
        for (int i = 0; i < cardArr.length; i++) {
            int index = (int) (Math.random() * CARD_NUM);
            Card temp = cardArr[i];
            cardArr[i] = cardArr[index];
            cardArr[index] = temp;
        }
    }
}
  • 다음의 코드는 Deck(카드 한 벌이라는 뜻) 클래스이다. 이 클래스를 통해 52장의 카드를 생성자를 통해 초기화하고 맨 위의 카드를 뽑는 메소드와 아무 카드를 뽑는 메소드를 만들었고 shuffle 메소드로 카드를 섞는 메소드도 만들었다.

  • 각 카드의 객체 생성을 위해 우선 Card 자료형의 배열을 선언한다. 생성자를 통해 각 카드들을 순서대로 초기화한다.

2.1 오버라이딩(Overriding)

  • 오버라이딩이란 부모 클래스로부터 상속받은 메소드의 내용을 변경하는 것이다. 오버라이딩의 조건으로는 이름이 같아야하고 매개변수와 리턴타입도 같아야한다. 오로지 바디부분만 변경되는 것을 오버라이딩이라고 한다. 오버라이딩을 하는 경우 부모 클래스의 메소드보다 자식 클래스의 메소드가 접근제어자가 같거나 더 넓은 범위여야만 한다. 대부분 같은 접근제어자를 사용한다. 그리고 부모 클래스의 메서드보다 더 많은 수의 예외를 선언할 수 없다. 마지막으로 인스턴스 메소드에서 static으로 또는 그 반대로 변경할 수 없다.
class Test extends Sample{
    static void test() {}
}
class Sample {
	void test() {} // 컴파일 오류!
}
  • 오버라이딩과 오버로딩은 몇번이고 반복해서 공부해도 이름이 비슷해서 자꾸 헛갈린다. 각각의 개념은 어느정도 숙지를 했는데 용어까지 확실하게 알아야 하는건지 잘 모르겠다. 물론 확실히 구분하는게 베스트겠지만 각각의 사용법을 알면 되지 않나라는 생각이 든다. 그래도 공부를 해보자면 오버로딩은 메소드 내용을 변경!하는 오버라이딩과는 다르게 이름만 같을뿐 새로운 메소드를 생성하는 것과 다를바가 없다. 매개변수도 달라지고 바디, 리턴타입도 달라진다.

2.4 super

위에 언급했듯이 super를 이용해서 자식 클래스의 생성자에서 부모 클래스의 생성자를 호출하는데 사용하기도 하고, 부모 클래스의 멤버변수와 자식 클래스의 멤버변수의 이름이 같은 경우 둘을 구붓짓기 위해 super.을 사용하기도 한다. 자식 클래스의 메소드에서 부모 클래스의 내용과 일치하는 부분이 있다면 super.부모클래스메소드를 호출하여 사용하기도 한다.

class Point {
    int x;
    int y;
    
    String getLocation() {
        return "x : " + x + ", y : " + y; 
    }
}

class Point3D extends Point {
    int z;
    String getLocation() {
        return super.getLocation() + ", z : " + z;
    }
}

3.1 패키지(Package)

패키지는 클래스의 묶음이다. 패키지를 이용하면 클래스를 그룹 단위로 묶어 효율적으로 관리할 수 있다. 모든 클래스는 패키지에 포함되어 있다. 지금까지 패키지를 만들지 않고도 클래스를 사용할 수 있었던 이유는 패키지를 따로 설정해주지 않으면 자바에서 자동적으로 패키지를 생성하기 때문이다. 하나의 클래스는 두개 이상의 패키지에 포함될 수 없고 단 하나의 패키지에 포함되어 있어야 한다. 패키지의 이름은 소문자로 짓는 것을 원칙으로 하고 있다.

import

import 문을 사용하면 다른 패키지 내의 클래스를 사용할 때 패키지명.클래스명에서 패키지명을 생략하고도 코드를 작성할 수 있다. 자바를 처음 배우기 시작했을 무렵에는 import를 어떻게 생각했냐면 '수입하다' 라는 의미 때문에 import 문을 작성하면 다른 패키지의 클래스를 다운(?)받아와서 사용하는 줄 알고 있었다. 그래서 방대한 양의 클래스가 다 내장되어 있으면 비효율적이라서 이렇게 다운받는 형식으로 import문을 사용하는줄 알고 있었다. 근데 그런 의미가 아니라 import문을 사용함으로써 패키지명을 생략해서 코드를 간결하게 해주다는걸 알게 되었다. import문은 package문 다음에, 클래스 선언문 이전에 위치한다.

import java.text.SimpleDateFormat;

public class Sample {
    public static void main(String[] args) {
        SimpleDateFormat date = new SimpleDateFormat("yyyy/MM/dd");
    }
}

public class Sample {
    public static void main(String[] args) {
        java.text.SimpleDateFormat date = new java.text.SimpleDateFormat("yyyy/MM/dd");
    }
}

4.3 final

  • final은 변경될 수 없는 이라는 뜻을 가지고 있다. 클래스에 사용되면 상속할 수 없다는 의미가 된다. 즉, 다른 클래스의 조상이 될 수 없다. 메서드에 final이 붙으면 오버라이딩을 통해 재정의 될 수 없다. 멤버변수나 지역변수에 붙으면 값을 변경할 수 없는 상수를 의미한다.

  • final이 붙은 변수는 상수이므로 일반적으로 선언과 동시에 초기화를 하지만 인스턴스 변수의 경우 생성자를 통해 초기화할 수도 있다. 생성자를 통한 상수의 초기화는 인스턴스마다 다른 상수값을 가지게 할 수 있다는 특징이 있다.

public class Sample {
    final int NUM;
    
    Sample(int num) {
        this.NUM = num;
        System.out.println("" + NUM);
    }

    public static void main(String[] args) {
        Sample sample1 = new Sample(1); // 1출력
        Sample sample2 = new Sample(2); // 2출력
        Sample sample3 = new Sample(3); // 3출력
    }
}

4.4 abstract

  • abstract는 추상의, 미완성의 라는 뜻을 가지고 있다. 클래스에 붙으면 미완성된 메소드를 포함하는 클래스라는 의미이고, 메서드에 붙으면 바디가 미완성되어 있으니 abstract class를 상속하는 클래스에서 바디를 완성해야만 한다. abstract 메서드는 선언부만 작성되어있고 바디{}없이 세미콜론;으로 메서드 선언부를 마친다.

  • abstract class는 미완성된 메소드가 존재하므로 인스턴스를 생성할 수 없다.

  • abstract class는 미완성된 메소드가 존재할 뿐 일반적인 클래스와 기능이 유사하므로 일반 메서드를 작성할 수 있고 일반 메서드에서 미완성된 abstract메서드를 호출하는 것도 가능하다.

4.5 접근제어자(access modifier)

  • 접근 제어자는 멤버나 클래스에 사용되며, 해당 멤버 또는 클래스를 외부에서 접근하지 못하도록 제한하는 역할을 한다. private은 해당 클래스 내에서만 접근할 수 있고, 접근제어자를 생략하면 자동적으로 붙는 default는 해당 패키지 내에서만 접근이 가능하다. protected는 해당 패키지와 다른 패키지 내의 자손 클래스에서 접근이 가능하다. public은 접근 제한이 없다.
package sample;

public class Sample {
    protected static void test() {
        System.out.println("hi");
    }
}
package test;

import sample.Sample;

public class Test extends Sample {
    public static void main(String[] args) {
        Test.test(); // hi 출력
    }
}
  • 접근제어자를 사용하는 이유는 클래스 내부에 선언된 데이터를 보호하기 위함이다. 데이터가 유지되도록, 함부로 변경되지 않도록 데이터(멤버변수, 클래스 등)에 접근제어자를 붙여 데이터 감추기(data hiding)를 하는 것이다. 이는 객체지향개념의 캡슐화에 해당하는 내용이다. 접근제어자를 사용하는 또다른 이유는 복잡성을 제거하기 위함이다. 클래스 내에서만 사용되거나 내부작업을 위해 임시로 사용되는 변수 또는 메서드 등을 감춰서 외부에 노출시키지 않음으로써 복잡성을 없앨 수 있다. 이 또한 캡슐화에 해당하는 내용이다.

  • 만약 메서드 하나를 변경해야 한다고 가정할 때 메서드의 접근 제어자가 public이라면 메서드를 변경한 후에 오류가 없는지 확인해야할 범위가 매우 넓지만 default라면 패키지 내부만 private라면 클래스 내부만 확인하면 된다.

  • Time이라는 클래스를 생성해서 hour,minute,second 멤버변수를 private으로 생성한 후 외부에서 멤버변수를 변경하지 못하도록 하고 그대신 메서드를 사용해서 변경하도록 코드를 작성해 보자.

package sample;

public class Time {
    private int hour, minute, second;

    Time(int hour, int minute, int second) {
        this.hour = hour;
        this.minute = minute;
        this.second = second;
    }
    Time() {
        this(0,0,0);
    }

    public void setHour(int hour) {
        if (hour<0 || hour>23) return;
        this.hour = hour;
    }

    public void setMinute(int minute) {
        if (minute<0 || minute>59) return;
        this.minute = minute;
    }

    public void setSecond(int second) {
        if (second<0 || second>59) return;
        this.second = second;
    }

    public int getHour() {return hour;}

    public int getMinute() {return minute;}

    public float getSecond() {return second;}

    public String toString() {
        return "( " + hour + " : " + minute + " : " + second + " )";
    }
}
  • 이런식으로 외부에서 멤버변수에 접근하지 못하도록 하고 메서드를 호출하여 변수의 값을 간접적으로 변경할 수 있도록 했을 때의 장점은 데이터를 보호할 수 있는건 말할 것도 없고, Time 클래스의 장점을 이야기해보자면 hour의 경우 0부터 23까지의 수만 시간으로 저장되야한다. 34시라는건 없다. 이런 제한을 메서드를 통해 두고 변수값을 변경할 수 있다는 장점이 있다.
package sample;

final class SingleTon {
    private static SingleTon s = new SingleTon();

    private SingleTon() {
    }

    public static SingleTon getInstance() {
        if (s == null)
            s = new SingleTon();
        System.out.println(s + " 생성 완료");
        return s;
    }
}

public class Sample {
    public static void main(String[] args) {
        SingleTon s1 = SingleTon.getInstance();
        // sample.SingleTon1@1b6d3586 생성 완료 출력
        SingleTon s2 = SingleTon.getInstance();
        // sample.SingleTon1@1b6d3586 생성 완료 출력
    }
}
  • 보통 생성자의 접근제어자는 클래스의 접근제어자와 같지만 다르게 이용해서 접근제어자로 객체 생성을 제한할 수도 있다.

  • 생성자에 private이 붙었다는 의미는 곧 객체를 생성할 수 없다는 의미가 된다. private는 클래스 내에서만 접근할 수 있기 때문에 클래스 내에서 객체를 생성하는건 가능하지만 다른 클래스에서는 객체를 생성할 수 없다.

  • 클래스 내부에 멤버변수로 참조변수를 선언한다. 선언시 private을 붙여서 외부에서 객체를 가르키는 참조변수를 호출할 수 없도록 하고, static을 붙여 getInstance 메소드에서 객체 생성없이 멤버변수를 사용할 수 있도록 한다.

  • getInstance 메소드를 생성할 땐 static을 붙여서 다른 클래스에서 메서드를 통해 객체를 생성할 때 객체를 생성하지 않고도 메서드를 호출할 수 있도록 한다. 메서드의 접근제어자는 public으로 하여 어디서든 메서드를 이용할 수 있도록 한다.

  • 멤버변수이자 참조변수값이 null인 경우에만 객체를 생성하도록 하고 참조변수를 리턴한다. 이렇게 하면 여러 객체를 생성해도 참조변수가 똑같은 주소값을 갖게 된다. 결국 객체는 하나만 생성되었다는 의미이다.

  • 생성자가 private이라는 것은 객체를 생성할 수 없는 클래스라는 의미라고 앞서 언급했다. 그 의미는 또 부모 클래스가 될 수 없다는 의미이기도 하다. 왜냐하면, 자식클래스가 객체를 생성할 때 자식클래스의 생성자에서 부모클래스의 생성자를 호출해야 하는데 자식 클래스의 생성자가 private이기 때문에 부모클래스의 생성자에서 호출이 불가능하기 때문이다. 이렇게 부모클래스가 될 수 없는 클래스들에 final을 명시하여 확장할 수 없는 클래스라는걸 알리는 것이 좋다.

5.1 다형성(Polymorphism)

  • 다형성이란 객체지향개념의 중요한 특징 중 하나로 여러가지 형태를 가질 수 있는 능력을 의미한다. 즉, 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있다는 의미이다. 더 구체적으로 말하자면 부모클래스 타입의 참조변수로 자식클래스의 인스턴스를 참조할 수 있는 특징을 다형성이라고 한다.

  • 부모클래스 타입의 참조변수로 자식클래스의 인스턴스를 참조한다는 것의 가장 큰 특징은 멤버의 개수의 변화이다. 자식클래스 타입의 참조변수로 자신의 인스턴스를 참조한 경우보다 멤버의 개수가 같거나 더 적다. 자식클래스의 인스턴스를 참조하고 있다고 하더라도 부모 클래스 내의 멤버만 호출이 가능하다.

  • 반대로 자식클래스 타입의 참조변수로 부모클래스의 인스턴스를 참조하는 것은 부모클래스에는 존재하지 않은 멤버를 사용하고자 할 가능성이 있으므로 허용하지 않는다.

5.4 매개변수의 다형성

  • 그렇다면 자식인스턴스 타입의 참조변수로 자식클래스의 인스턴스를 참조하면 더 많은 멤버 개수를 가질 수 있는데 굳이 왜 부모클래스 타입의 참조변수로 자식클래스의 인스턴스를 참조하는 것을 허용하게 했고 왜 사용할까? 수많은 자식클래스가 있다고 가정할 때, 그 자식클래스를 매개변수로 받는 메소드가 있다고 해보자. 각 자식클래스마다 메소드를 오버로딩하여 매개변수를 해당 자식클래스 타입의 참조변수를 두는 것보다 하나의 메소드에 부모클래스 타입의 참조변수를 인스턴스로 두면 지금까지 설명했던 다형성 개념에 따라 부모클래스 타입의 참조변수로 모든 자식클래스의 인스턴스를 참조할 수 있기 때문에 코드가 간결해지고 유지보수가 쉬워질 것이다. 예를 들어, 전자상가에서 가전제품을 구입하는 프로그램을 만든다고 할때,
package sample;

class Product {
    int price;
    int bonusPoint;

    Product(int price) {
        this.price = price;
        bonusPoint = (int) price / 10;
    }
}

class Tv extends Product {
    Tv() {
        super(100);
    }

    @Override
    public String toString() {
        return "Tv";
    }
}

class Computer extends Product {
    Computer() {
        super(200);
    }

    @Override
    public String toString() {
        return "Computer";
    }
}

class Buyer {
    int money = 1000;
    int bonusPoint = 0;

    void buy(Product p) {
        if (money < p.price) {
            System.out.println("돈 부족");
            return;
        }
        money -= p.price;
        bonusPoint += p.bonusPoint;
        System.out.println(p + " 을/를 구입");
    }
}

public class Sample {
    public static void main(String[] args) {
        Buyer buyer = new Buyer();
        Tv tv = new Tv();
        Computer computer = new Computer();

        buyer.buy(tv);
        buyer.buy(computer);
    }
}
  • Product 클래스를 부모클래스로 두고 멤버변수로 제품가격과 포인트를 선언하고 생성자를 만들어서 가격을 초기화하도록 했다.

  • 각각의 가전제품 클래스를 만들고 Product 클래스를 확장했다. 각각의 생성자는 부모 생성자를 호출해야 하기 때문에 super()로 호출하고 괄호안에 각각의 가격을 적는다. Object 클래스의 메서드인 toString메서드를 오버라이딩하여 각 클래스의 이름을 리턴한다.

  • Buyer 클래스에는 구매자의 현재 가지고 있는 돈과 포인트를 초기화 시켰고, buy메서드를 만들어 구매하는 기능을 추가하였다. buy 메서드의 매개변수로는 각각의 자식 타입의 참조변수를 사용해도 좋지만 모든 인스턴스를 참조할 수 있는 부모 타입의 참조변수를 매개변수로 두어서 코드를 간결하게 했다.

5.6 여러 종류의 객체를 배열로 다루기

  • 부모 타입의 참조 변수에 부모,자식 인스턴스를 참조할 수 있는 것 뿐만 아니라 배열도 부모 타입이면 각기 다른 부모,자식 인스턴스를 참조할 수 있다. 원래 지금까지 배웠던 같은 타입의 element만 가진 배열의 특징과는 다른, 다형성 개념이다. 위의 예에서 더 이어가서 구매자가 구입한 가전제품을 배열에 정리하면,
class Buyer {
    int money = 1000;
    int bonusPoint = 0;
    int index = 0; // itemList 의 인덱스로 사용될 변수
    Product[] itemList = new Product[10];

    void buy(Product p) {
        if (money < p.price) {
            System.out.println("돈 부족");
            return;
        }
        money -= p.price;
        bonusPoint += p.bonusPoint;
        itemList[index++] = p;
        System.out.println(p + " 을/를 구입");
    }

    void summery() {
        System.out.println("남은 돈 : " + money);
        System.out.println("포인트 : " + bonusPoint);
        for (int i=0; i<itemList.length; i++) {
            if (itemList[i] == null) break;
            System.out.printf("%s\t",itemList[i]);
        }
    }
}
  • Product 타입의 배열을 적당한 크기로 선언하고, buy 메서드에서 매개변수로 들어오는 제품을 Product 타입의 배열에 index 변수를 사용해서 순서대로 넣는다.

  • Summery 메서드를 추가해서 현재 정보와 itemList에 쌓여있는 제품들을 출력하기 위해 반복문을 사용한다. element값이 null이면 반복문을 탈출하도록 한다.

  • 제품을 얼마나 구입할지 몰라 충분한 크기의 배열을 사용했지만 예상과는 다르게 배열의 크기를 넘어서도록 제품을 구입할 수도 있다. 이럴 경우 일반적인 배열을 사용하기 보다는 Vector 클래스를 이용하면 배열의 크기를 알아서 관리해주기 때문에 저장할 인스턴스 개수에 신경쓰지 않아도 된다.

class Buyer {
    int money = 1000;
    int bonusPoint = 0;
    Vector itemList = new Vector();

    void buy(Product p) {
        if (money < p.price) {
            System.out.println("돈 부족");
            return;
        }
        money -= p.price;
        bonusPoint += p.bonusPoint;
        itemList.add(p);
        System.out.println(p + " 을/를 구입");
    }

    void refund(Product p) {
        if (itemList.remove(p)) {
            money += p.price;
            bonusPoint -= p.bonusPoint;
            System.out.println(p + " 을/를 환불");
        } else {
            System.out.println(p + " 없음");
        }
    }

    void summery() {
        String items = "";

        System.out.println("남은 돈 : " + money);
        System.out.println("포인트 : " + bonusPoint);
        for (int i = 0; i < itemList.size(); i++) {
            Product p = (Product) itemList.get(i);
            items += (i == 0) ? "" + p : ", " + p;
        }
        System.out.println(items);
    }
}

6.1 추상클래스

  • 클래스가 설계도라면 추상클래스는 미완성 설계도이다. 새로운 클래스를 작성하는데 있어서 바탕이 되는 부모클래스로서의 중요한 의미를 갖는다. 새로운 클래스를 작성할 때 아무것도 없는 상태에서 시작하기 보다는 완전하지는 않더라도 틀을 어느정도 갖춘 상태에서 시작하는 것이 더 나을 것이다. 이러한 역할을 해주는게 추상클래스이다.

  • 추상클래스는 추상메서드를 포함하고 있는 클래스이고, 일반클래스처럼 추상메서드 이외에도 일반메서드나 멤버변수를 가질 수 있다. 또한 일반 메서드에서 추상메서드를 호출할 수도 있다.

  • 추상메서드는 선언부만 작성하고 바디는 작성하지 않는다. 추상클래스를 확장할 여러 클래스에서 오버라이딩하여 각 클래스의 조건에 따라 다른 내용의 메서드를 작성하면 된다.

  • 일반 메서드도 바디를 작성하지 않을 수 있는데 굳이 abstract를 붙여 추상메서드를 사용하는 이유는 뭘까? 일반 메서드와 달리 자식 클래스에서 추상메서드를 오버라이딩하도록 강제하기 때문이다.

  • 프로그램을 작성하는 방법에는 각 클래스의 공통적인 부분을 미리 파악하여 추상클래스를 먼저 작성하는 방법이 있고, 먼저 클래스들을 작성한 후 공통적인 부분을 뽑아 추상클래스를 나중에 작성하는 방법이 있다. 상속계층도를 따라 내려갈수록 구체화된다고 표현하고, 올라갈수록 추상화된다고 표현한다.

7.1 인터페이스

추상메서드와 인터페이스는 거의 유사한 기능을 갖지만 인터페이스가 보다 추상화의 정도가 높다. 인터페이스는 오로지 추상메서드(abstract)나 상수(final)만을 가질 수 있다. 일반메서드나 멤버변수는 가질 수 없다. 인터페이스 역시 추상클래스와 마찬가지로 불완전하기 때문에 그 자체로 사용되기 보다는 클래스를 작성하는데 도움을 줄 목적으로 작성된다.

  • 인터페이스의 상수나 메서드에 붙는 제어자는 정해져 있으며 예외가 없기 때문에 생략이 가능하다.
interface name {
	public static final 상수 =;
    public abstract 메서드;
}
  • 제어자가 정해져 있다는 특징 때문에 유의해야할 점이 있는데 추상메서드(public abstract)를 자식메서드가 오버라이딩하는 경우 접근제어자를 추상메서드보다 같거나 넓게 해야하기 때문에 무조건 자식메서드는 public이 되어야만 한다. 접근제어자를 적지 않으면 defualt가 자동으로 붙기 때문에 public을 꼭 작성해야 한다.
package sample;

interface Movable {
    void move(int x, int y); // public abstract 생략되어 있음
}

class Marine implements Movable {
    @Override
    public void move(int x, int y) {}
}
  • 추상클래스와 마찬가지로 인터페이스의 여러 추상메서드 중 일부만을 자식클래스가 구현한다면 자식클래스 역시 abstract를 붙여 일부가 구현이 안되었다는걸 명시해야만 한다.

  • 단일상속 특징을 지닌 클래스와 달리 인터페이스는 다중상속이 가능하다는 특징이 초점이 맞춰지는 바람에 다중상속이 장점으로 느껴질 수 있는데 인터페이스로 다중상속을 구현하는 경우는 거의 없다고 한다.

  • 리턴타입이 인터페이스라는 것은/ 메서드가/ 해당 인터페이스를 구현한 클래스의 인스턴스를/ 반환한다는 것을 의미한다./(중요) 다음은 이를 설명하기 위해 파일의 확장자에 따라 파일 분석을 달리 하는 프로그램을 짜는 예를 들어 보겠다.

package sample;

interface Parseable {
    void parse(String fileName); // public abstract 생략
}

class ParserManager {
    public static Parseable getParser(String type) {
        // 리턴 타입이 Parseable 인터페이스
        if (type.equals("XML")) return new XMLParser();
        else {
            Parseable p = new HTMLParser();
            return p; // new HTMLParser() 리턴
        }
    }
}

class XMLParser implements Parseable {
    @Override
    public void parse(String fileName) {
        System.out.println(fileName + "- XML parsing complete.");
    }
}

class HTMLParser implements Parseable {
    @Override
    public void parse(String fileName) {
        System.out.println(fileName + "- HTML parsing complete.");
    }
}

public class Sample {
    public static void main(String[] args) {
        Parseable parser = ParserManager.getParser("XML");
        parser.parse("document.xml");
        parser = ParserManager.getParser("HTML");
        parser.parse("document2.html");

    }
}
  • 일단 분석이 가능한 파일들을 그룹화하기 위해 Parseable 인터페이스를 생성한 후 파일 이름을 매개변수로 하는 parse 추상 메서드를 선언한다.

  • 각 확장자 별로 파일을 분석하는 클래스를 생성한 후 Parseable 인터페이스를 구현하여 parse 메서드의 바디를 작성한다.

  • ParserManager라는 클래스를 생성하여 getParer 메서드로 파일 이름을 매개변수로 받으면 확장자에 따라 파일 분석을 달리 한 후, 해당 확장자 파일의 분석을 하는 클래스의 인스턴스를 리턴하도록 한다. 여기서 리턴타입은 각각의 클래스의 인스턴스를 모두 참조할 수 있는 Parseable 인터페이스 타입으로 한다.

  • getParser 메서드의 내용은 매개변수로 어떤 확장자를 매개변수로 받느냐에 따라 해당 클래스의 인스턴스를 반환하도록 한다. 해당 인스턴스를 반환함으로써 인스턴스가 파일을 분석 해준다.

7.7 인터페이스의 장점

  • 첫번째는 개발시간을 단축할 수 있다는 점이다. 메서드를 호출하는 쪽(user)에서는 메서드의 내용과 관계없이 선언부만 알면 호출해서 사용할 수 있다. 이와 동시에 메서드의 내용을 작성하는 쪽(provider)에서는 인터페이스를 구현한 클래스 내에서 메서드를 완성해 나갈 수 있다.
package sample;

class A { // User
    public void methodA(B b) {
        b.methodB();
    }
}

class B { // Provider
    public void methodB() {
        System.out.println("methodB");
    }
}

public class Sample {
    public static void main(String[] args) {
        A a = new A();
        a.methodA(new B());
    }
}
  • 다음의 코드는 클래스 A,B가 직접적으로 연관되어 있는 코드이다. A 클래스 내에 있는 methodA 메서드에 B 클래스의 인스턴스를 참조하는 참조변수를 매개변수로 선언하여 참조변수를 통해 methodB를 호출했다. 직접적인 관계로 이루어진 클래스들의 단점은 한 쪽이 변경되면 다른 한쪽도 변경되어야 해서 번거롭다. 그리고 호출할 메서드가 작성되어 있어야지만 User가 메서드를 호출할 수 있다. 이런 직접적인 관계의 두 클래스에 인터페이스가 끼게 되어 methodB를 인터페이스를 통해 호출하게 되면 이런 단점들을 해결할 수 있다.
package sample;

interface I {
    void methodB();  // public abstract 생략
}

class A {
    public void methodA(I i) {
        i.methodB();
    }
}

class B implements I {
    @Override
    public void methodB() {
        System.out.println("methodB in B class");
    }
}

class C implements I {
    @Override
    public void methodB() {
        System.out.println("methodB in C class");
    }
}

public class Sample {
    public static void main(String[] args) {
        A a = new A();
        a.methodA(new B());
        a.methodA(new C());
    }
}
  • 인터페이스를 통해 클래스 B의 메서드를 호출하게 되면 클래스 B의 변경에 영향을 받지 않는다. 심지어 클래스의 이름을 몰라도 되고 클래스 자체가 존재하지 않아도 문제가 안된다. User는 인터페이스와 직접적인 관계가 있기 때문이다.

  • 인터페이스의 두번째 장점은 서로 관계없는 클래스들에게 인터페이스를 공통적으로 구현해 관계를 맺어줄 수 있다.

  • 클래스와 클래스 간의 직접적인 관계(상속)를 인터페이스를 이용해서 간접적인 관계로 변경하면, 한 클래스의 내용을 변경해야 하는 경우, 다른 클래스에 영향을 미치지 않도록 독립적인 프로그래밍이 가능하다.

7.9 디폴트 메서드

  • 조상 클래스에 새로운 메서드를 추가하는 것은 별일 아니지만, 인터페이스의 경우엔 추상 메서드를 하나 추가함으로써 인터페이스를 구현한 모든 클래스 내에 새로운 메서드가 추가되는 것이고 그 메서드를 클래스마다 구현해 내야하기 때문에 매우 큰 일이 된다.

  • 인터페이스가 변경이 되지 않는 경우가 최상이겠지만 아무리 설계를 잘해도 변경해야 할 경우가 생긴다. 이럴 경우 디폴트 메서드를 사용하면 된다. 인터페이스에 추상메서드만 추가가 가능하다고 했지만 업데이트가 되어 디폴트 메서드가 추가되었다. 디폴트메서드는 추상메서드와 달리 바디가 있어야하고, 접근 제어자가 public이며 생략 가능하다. 추상클래스에서의 일반메서드와 비슷한 기능을 하게 되는 것이다. 인터페이스를 구현한 클래스들은 디폴트메소드를 상속 받게 된다.

8.1 내부 클래스(Inner class)

  • 내부 클래스란 클래스 내부에 선언된 클래스이다. 한 클래스를 다른 클래스의 내부 클래스로 선언하면 두 클래스의 멤버들 간에 서로 쉽게 접근할 수 있다는 장점이 있고, 외부에는 불필요한 클래스를 감춤으로써 코드의 복잡성을 줄이는 장점도 있다. 즉, 캡슐화에 해당하는 장점이다. 내부 클래스는 외부클래스를 제외하고는 잘 사용되지 않아야 내부 클래스로서의 의미가 있을 것이다.
class A {
	class B {
    
    }
}
  • 내부 클래스 역시 인스턴스 클래스, 스태틱 클래스, 지역 클래스, 익명 클래스가 있으며 각 종류들은 멤버의 종류와 유사한 특징을 지니고 있다.

  • 인스턴스클래스와 스태틱 클래스는 외부 클래스의 멤버변수와 같은 위치에 선언되며 멤버변수와 같은 성질을 갖는다.

  • 내부클래스는 클래스의 의미를 갖기도 하고 외부클래스의 입장에서는 멤버변수의 의미도 갖고 있기 때문에 모든 접근제어자를 사용할 수 있다.

0개의 댓글