[Section 1] 다형성, 추상화

Kim·2022년 9월 7일
0

Boot Camp

목록 보기
14/64
post-thumbnail

객체지향의 기둥은 상속, 캡슐화, 다형성, 추상화 라고 배웠는데, 오늘은 다형성과 추상화에 대해 정리하고자 한다.


다형성

다형성이란 객체지향 프로그래밍의 가장 핵심적인 부분이라 할 수 있다.
일반적으로 다형성이란 하나의 객체가 여러 형태를 가질 수 있는 성질을 의미한다. 자바에서의 다형성이란 한 타입의 참조변수를 통해 여러 타입의 객체를 참조할 수 있게 만든 것이다.
정리하자면, 상위 클래스 타입의 참조 변수를 통해서 하위 클래스의 객체를 참조할 수 있게 허용한 것이다.

//참조변수의 다형성
class Friend {
    public void friendInfo() {
        System.out.println("나는 너의 친구야.");
    }
}
class BoyFriend extends Friend { //Friend 클래스를 상속 받음
    public void friendInfo() {
        System.out.println("나는 너의 남자친구야.");
    }
}
class GirlFriend extends Friend { //Friend 클래스를 상속 받음
    public void friendInfo() {
        System.out.println("나는 너의 여자친구야.");
    }
}
public class FriendTest {
    public static void main(String[] args) {
        Friend friend = new Friend(); //객체 타입과 참조변수 타입의 일치
        BoyFriend boyfriend = new BoyFriend();
        Friend girlfriend = new GirlFriend(); //객체 타입과 참조변수 타입의 불일치
        //GirlFriend friend1 = new Friend(); >>하위 클래스 타입으로 상위 클래스 객체 참조 불가

        friend.friendInfo();
        boyfriend.friendInfo();
        girlfriend.friendInfo();
    }
}

위 코드에서 참조 변수 friendboyfriend 모두 각각 FriendBoyFriend 타입과 일치하는 참조 변수 타입을 사용하고 있다.

상위 클래스 타입의 참조변수로 하위 클래스의 객체를 참조하는 것

그 다음 라인을 보면 GirlFriend 클래스의 인스턴스를 생성하고, 그것을 Friend 타입의 참조변수인 gitlfriend에 할당하고 있다. GirlFriend를 참조변수의 타입으로 지정해줘야 하는데, 상위 클래스 Friend를 타입으로 지정하고 있다.
상위 클래스를 참조 변수의 타입으로 지정했기 때문에 참조 변수가 사용할 수 있는 멤버의 개수는 자연스럽게 상위 클래스의 멤버의 수가 된다. 이것이 상위 클래스 타입의 참조 변수로 하위 클래스의 객체를 참조하는 것이다.

public class FriendTest {

    public static void main(String[] args) {
        Friend friend = new Friend(); // 객체 타입과 참조변수 타입의 일치 -> 가능
        BoyFriend boyfriend = new BoyFriend();
        Friend girlfriend = new GirlFriend(); // 객체 타입과 참조변수 타입의 불일치 -> 가능
		//GirlFriend friend1 = new Friend(); -> 하위클래스 타입으로 상위클래스 객체 참조 -> 불가능

        friend.friendInfo();
        boyfriend.friendInfo();
        girlfriend.friendInfo();
    }
}

위 코드를 확인해보자. 상위 클래스인 Friend 타입으로 하위 클래스 GiflFriend를 참조하는 것은 가능하다. 하지만, 반대로 하위 클래스 GirlFriend 타입으로 상위 클래스의 객체인 Friend를 참조하는 것은 불가능하다.
그 이유는 실제 객체인 Friend의 멤버 개수보다 참조변수 friend1이 사용할 수 있는 멤버의 개수가 더 많기 때문이다.

실제로 참조하고 있는 인스턴스의 멤버를 기준으로, 참조 변수의 타입의 멤버가 실제 인스턴스의 멤버 수보다 작으면 사용할 수 있는 기능을 줄이는 것이기에 가능하다.
반대의 경우, 참조하고 있는 인스턴스에 실제로 구현된 기능이 없어 사용이 불가한 것이다.

실제 객체 >= 참조 변수 = 가능
실제 객체 < 참조 변수 = 불가능

지난 번에 배웠던 메서드 오버라이딩과 메서드 오버로딩 또한 다형성의 한 예시라고 할 수 있다.

참조 변수의 타입 변환

참조 변수의 타입 변환은 사용할 수 있는 멤버의 개수를 조절하는 것을 말한다.
자바에서의 다형성이란 세 가지 조건을 충족해야 한다.

  1. 서로 상속관계인 상위 클래스---하위 클래스 사이에서만 타입 변환이 가능
  2. 하위 클래스 타입에서 상위 클래스 타입으로의 타입 변환(업캐스팅)은 형변환 연산자() 생략 가능
    하위 클래스▷▶상위 클래스 (Upcasting)
  3. 상위 클래스에서 하위 클래스 타입으로 변환(다운캐스팅)은 형병환 연산자()를 반드시 명시
    상위 클래스▷▶히위 클래스 (Downcasting)
public class VehicleTest {
    public static void main(String[] args) {
        Car car = new Car();
        Vehicle vehicle = (Vehicle) car; //상위 클래스 Vehicle 타입으로 변환(생략 가능)
        Car car2 = (Car) vehicle; //하위 클래스 Car타입으로 변환(생략 불가능)
        MotorBike motorBike = (MotorBike) car; //에러 발생 : 상속관계가 아니므로 타입 변환 불가
    }
}

class Vehicle {
    String model;
    String color;
    int wheels;

    void startEngine() {
        System.out.println("시동 걸기");
    }

    void accelerate() {
        System.out.println("속도 올리기");
    }

    void brake() {
        System.out.println("브레이크!");
    }
}

class Car extends Vehicle {
    void giveRide() {
        System.out.println("다른 사람 태우기");
    }
}

class MotorBike extends Vehicle {
    void performance() {
        System.out.println("묘기 부리기");
    }
}

Vehicle 클래스로부터 각각 상속을 받아 CarMotorBike 클래스가 만들어졌다.
먼저, Car 클래스의 인스턴스 객체인 car를 생성하고 그 객체를 가리키는 참조 변수인 vehicle의 타입을 Vehicle로 지정하여 참조 변수의 타입 변환을 실행했다.
그 후, 반대로 vehicle를 하위 클래스 타입인 car로 타입 변환해 참조 변수 car2에 할당했다.

이렇게 상속 관계인 클래스 간의 상호 타입 변환은 자유롭게 수행될 수 있다.
다만 하위 클래스를 상위 클래스 타입으로 변환하는 경우, 타입 변환 연산자()를 생략할 수 있지만, 반대의 경우 타입 변환 연산자를 생략할 수 없다.

Car 클래스와 MotorBike 클래스는 상속 관계가 아니기에 타입 변환이 불가해 에러가 발생했다.

instanceof 연산자

instanceof 연산자는 참조 변수의 타입 변환이 가능한지, 즉 캐스팅이 가능한지의 여부를 boolean 타입으로 확인할 수 있는 자바의 문법 요소다.

캐스팅 여부를 판단하기 위해서는 객체를 어떤 생성자로 만들었고, 클래스 사이에 상속 관계가 존재하는지를 판단해야 한다.
프로젝트 규모가 커지면 이러한 정보를 확인하기 어려워지는데, 이를 해결하기 위해 instanceof 연산자를 제공한다.

참조_변수 instanceof 타입

위 코드를 입력했을 때 리턴 값이 true가 나오면 참조 변수가 검사한 타입으로 타입 변환이 가능하다. false가 나오면 타입 변환은 불가능하다.
만일 참조 변수가 null인 경우 false를 반환한다.

public class InstanceOfExample {
    public static void main(String[] args) {
        Animals animals = new Animals();
        System.out.println(animals instanceof Object); //true
        System.out.println(animals instanceof Animals); //true
        System.out.println(animals instanceof Bat); //false

        Animals tiger = new Tiger();
        System.out.println(tiger instanceof Object); //true
        System.out.println(tiger instanceof Animals); //true
        System.out.println(tiger instanceof Tiger); //true
        System.out.println(tiger instanceof Bat); //false
    }
}

class Animals {};
class Bat extends Animals{};
class Tiger extends Animals{};

Animals 클래스를 BatTiger 클래스가 각각 상속 받고 있다. 그리고 객체를 생성하여 Animals 타입의 참조 변수에 넣고 instanceof 키워드를 사용해 형변환 여부를 확인한다.

Tiger 객체를 보면, 생성된 객체는 Animals 타입으로 선언되어 있다. 다형적 표현 방법에 따라 ObjectAnimals 타입으로도 선언될 수 있다.

소스 코드가 길어지는 등 하나하나 생성 객체의 타입을 확인하기 어려운 상황에서 instanceof 연산자는 형변환 여부를 확인해 에러를 최소화하는 유용한 수단이다.


추상화

추상의 사전적 의미는 사물이나 표상을 어떤 성질이나 공통성, 본질에 착안해 그것을 추출하여 파악하는 것이라고 정의되고 있다. 여기서 핵심 개념은 공통성과 본질을 모아 추출하는 것이다.

자바에서의 추상화는 객체의 공통적인 속성과 기능을 추출해 정의하는 것을 말한다.
상속이 하위 클래스를 정의하는데 상위 클래스를 사용하는 것이라면, 추상화는 기존 클래스들의 공통적인 요소를 뽑아 상위 클래스를 만들어내는 것이다.

공통적인 속성과 기능을 모아 정의하면 코드의 중복을 줄일 수 있고, 보다 효과적으로 클래스 간의 관계를 설정할 수 있다. 또한, 유지보수가 용이해진다.

abstract 제어자

abstract의 사전적인 의미는 '추상적인'이라는 뜻을 가지고 있다. 자바의 맥락에서는 '미완성'이라 정리할 수 있다.

abstract는 주로 클래스와 메서드를 형용하는 키워드로 사용된다. 메서드 앞에 붙은 경우를 추상 메서드, 클래스 앞에 붙은 경우를 추상 클래스라고 부른다.
어떤 클래스에 추상 메서드가 포함된 경우 해당 클래스는 자동으로 추상 클래스가 된다.

abstract class AbstractExample { //추상 메서드가 최소 하나 이상 포함돼있는 추상 클래스
	abstract void start(); //메서드 바디가 없는 추상메서드
}

추상 메서드는 메서드의 시그니처만 존재하고 바디가 없는 메서드를 말한다. 메서드의 이름 앞에 abstract 키워드를 붙여주어 해당 메서드가 추상 메서드임을 표시한다.

즉, 추상 메서드는 충분히 구체화되지 않은 미완성 메서드다. 미완성 메서드를 포함하는 클래스는 미완성 클래스를 의미하는 추상 클래스가 된다.
추상 클래스는 미완성 설계도이므로 메서드 바디가 완성되기 전까지는 객체 생성이 불가하다.

추상 클래스

추상 클래스는 메서드 시그니처만 존재하고 바디가 선언되어 있지 않은 추상 메서드를 포함하는 미완성 설계도다. 또한, 미완성 구조기 때문에 객체를 생성하는 것이 불가능하다.

추상 클래스는 상속 관계에 있어서 새로운 클래스를 작성하는데 매우 유용하다.
메서드의 내용은 상속을 받는 클래스에 따라 종종 달라지는데, 상위 클래스에서는 선언부만을 작성하고 실제 구체적인 내용은 상속을 받는 하위 클래스에서 구현하도록 비워두면 설계 상황이 변하더라도 유연하게 대응할 수 있다.
오버라이딩을 통해서 추상 클래스로부터 상속받은 추상 메서드의 내용을 구현해 메서드를 완성시킬 수 있다. 이렇게 완성된 클래스를 기반으로 해당 객체를 생성할 수 있다.

다시 말해서 추상 클래스란?

정리하자면 추상 클래스를 사용하면 상속 받는 하위 클래스에서 오버라이딩을 통해 각각 상황에 맞는 메서드 구현이 가능하다. 또한, 추상 클래스는 추상화를 구현하는데 핵심적인 역할을 수행한다.
구체화에 반대되는 개념으로, 상속 계층도의 상층부에 위치할 수록 추상화의 정도가 높고 그 아래로 갈 수록 구체화 된다고 정리할 수 있다. 상층부에 가까울 수록 더 공통적인 속성과 기능들이 정의되어 있는 것이다.

final 키워드

final 키워드는 필드, 지역 변수, 클래스 앞에 위치할 수 있고, 위치에 따라 의미가 달라진다.

위치의미
클래스변경 또는 확장이 불가한 클래스 (상속 불가)
메서드오버라이딩 불가
변수값의 변경이 불가한 상수
final class FinalEx { //확장 or 상속 불가능한 클래스
	final int x = 1; //변경되지 않는 상수

	final int getNum() { //오버라이딩 불가한 메서드
		final int localVar = x; //값 변경 불가한 상수
		return x;
	}
}

각각의 클래스, 메서드, 변수 앞에 final 제어자가 추가되면 해당 대상은 더이상 변경이 불가하거나 확장되지 않는 성질을 지니게 된다.

인터페이스

컴퓨터 프로그래밍에서 사용하는 인터페이스는 서로 다른 두 시스템이나 장치, 소프트웨어 등을 서로 이어주는 부분 혹은 접속 장치라 할 수 있다.

인터페이스도 추상 클래스처럼 추상화를 구현하는데 활용되는데, 추상 클래스에 비해 더 높은 추상성을 갖는다.
추상 클래스가 미완성 설계도라면 인터페이스는 가장 기초적인 밑그림이다.
추상 클래스는 추상 메서드를 하나 이상 포함하는 점 외에는 일반 클래스와 동일하다 할 수 있다. 반면에 인터페이스는 추상 메서드와 상수만을 멤버로 가질 수 있다는 점에서 추상화 정도가 더 높다고 할 수 있다.

기본 구조

인터페이스를 작성하는 것은 클래스를 작성하는 것과 유사하나, interface 키워드를 사용한다.
일반 클래스와 다르게 내부의 모든 필드가 public static final로 정의되고 staticdefault 메서드 이외의 모든 메서드가 public abstract로 정의된다.

public interface InterfaceEx {
    public static final int rock =  1; //인터페이스 인스턴스 변수 정의
    final int scissors = 2; //public static 생략
    static int paper = 3; //public & final 생략

    public abstract String getPlayingNum();
		void call() //public abstract 생략 
}

인터페이스 안에서 상수를 정의할 때는 public static final, 메서드를 정의할 때는 public abstract로 정의되어야 하는데, 위 코드와 같이 일부분 혹은 전부 생략이 가능하다.
생략된 부분은 컴파일러가 자동으로 추가한다.

인터페이스 구현

추상 클래스와 마찬가지로 인터페이스도 그 자체로 인스턴스를 생성할 수 없다. 메서드 바디를 정의하는 클래스를 따로 작성해야 하는데, implements 키워드를 사용한다.

class 클래스명 implements 인터페이스명 {
		... //인터페이스에 정의된 모든 추상메서드 구현
}

특정 인터헤이스를 구현한 클래스는 해당 인터페이스에 정의된 모든 추상 메서드를 구현해야 한다.
다시 말해 A 클래스에 특정 인터페이스를 구현한다는 것은 A 클래스에게 인터페이스의 추상 메서드를 반드시 구현하도록 강제하는 것이다.
즉, 인터페이스를 구현한다는 것은 그 인터페이스가 가진 모든 추상 메서드들을 해당 클래스 내에서 오버라이딩하여 바디를 완성한다.

다중 구현

클래스 간의 다중 상속은 허용되지 않지만, 인터페이스의 경우 다중 구현이 가능하다.
단, 인터페이스는 인터페이스로부터의 상속만 가능하고 클래스와 다르게 Object 클래스 같은 최상위 클래스가 존재하지 않는다.

interface Animal { //인터페이스 선언 (public abstract 생략 가능)
    public abstract void cry();
}

interface Pet {
    void play();
}

class Dog implements Animal, Pet { //Animal과 Pet 인터페이스 다중 구현
    public void cry(){ //메서드 오버라이딩
        System.out.println("멍멍!");
    }

    public void play(){ //메서드 오버라이딩
        System.out.println("원반 던지기");
    }
}

class Cat implements Animal, Pet { //Animal과 Pet 인터페이스 다중 구현
    public void cry(){
        System.out.println("야옹!");
    }

    public void play(){
        System.out.println("쥐 잡기");
    }
}

public class MultiInheritance {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();

        dog.cry();
        dog.play();
        cat.cry();
        cat.play();
    }
}

DogCat 클래스는 각각 AnimalPet 인터페이스를 다중으로 구현해 각각의 객체에 맞는 메서드를 오버라이딩 한 후, 그 내용을 출력값으로 돌려주고 있다.

인터페이스가 다중 구현이 가능한 이유는 무엇일까?

클래스가 다중 상속이 불가한 핵심 이유는 만일, 부모 클래스에 동일한 이름의 필드나 메서드가 존재하는 경우 충돌이 발생하기 때문이다.
인터페이스의 경우, 애초에 미완성인 멤버를 갖고 있어서 충돌이 발생할 여지가 없어 다중 구현이 가능한 것이다.

장점

public class InterfaceExample {
    public static void main(String[] args) {
        User user = new User(); //User 클래스 객체 생성
        user.callProvider(new Provider()); // Provider 객체 생성 후에 매개변수로 전달
    }
}

class User { //User 클래스
    public void callProvider(Provider provider) { // Provider 객체를 매개변수로 받는 callProvider 메서드
        provider.call();
    }
}

class Provider { //Provider 클래스
    public void call() {
        System.out.println("야호~");
    }
}

Provider 클래스에 의존하고 있는 User 클래스가 있다. 의존한다는 것은 User 클래스가 Provider 클래스에 정의된 특정 속성이나 기능을 가져와서 사용하고 있다는 것이다.

위의 코드에서 User 클래스에 정의된 callProvider 메서드의 매개변수로 Provider 타입이 전달되어 호출되고 있다.

의존하고 있는 클래스에 변경 사항이 발생해 다른 클래스로 교체해야 한다면?

만일 User 클래스가 의존하고 있는 Provider 클래스에서 변경 사항이 발생해 Provider2 클래스로 교체해야되는 상황이 발생한다면 아래의 코드와 같이 변경할 수 있다.

public class InterfaceExample {
    public static void main(String[] args) {
        User user = new User(); //User 클래스 객체 생성
        user.callProvider(new Provider2()); //Provider객체 생성 후에 매개변수로 전달
    }
}

class User { //User 클래스
    public void callProvider(Provider2 provider) { //Provider 객체를 매개변수로 받는 callProvider 메서드
        provider.call();
    }
}

class Provider2 { //Provider 클래스
    public void call() {
        System.out.println("야호!!!");
    }
}

User 클래스의 의존 관계를 Provider2 클래스로 변경하기 위해 Provider2 객체를 새로 생성해주었다. User 클래스의 callProvider 메서드가 동일한 타입의 매개변수를 받을 수 있게 매개변수의 타입도 Provider2라 변경했다.

인터페이스를 사용하는 이유!

만약 수정해야 할 코드가 수 백줄에 이른다면, 위와 같이 수많은 코드 라인을 하나하나 수정해야한다.
인터페이스의 가장 큰 장점은 역할과 구현을 분리시켜 복잡한 구현의 내용이나 변경과 상관 없이 해당 기능을 사용할 수 있다는 점이다.

interface Cover { //인터페이스 정의
    public abstract void call();
}

public class Interface4 {
    public static void main(String[] args) {
        User user = new User();
		//Provider provider = new Provider();
		//user.callProvider(new Provider());
        user.callProvider(new Provider2());
    }
}

class User {
    public void callProvider(Cover cover) { //매개변수의 다형성 활용
        cover.call();
    }
}

class Provider implements Cover {
    public void call() {
        System.out.println("야호~");
    }
}

class Provider2 implements Cover {
    public void call() {
        System.out.println("야호!!!");
    }
}

기존의 Provider 클래스에 인터페이스를 적용한 모습이다. 이제 USer 클래스는 Provider 클래스의 교체나 내용의 변경에 상관없이 인터페이스와의 상호작용을 통해 의도한 목적을 달성할 수 있다.

먼저 Cover 인터페이스를 정의한 후에 각각의 구현체에 implements 키워드를 사용하여 각각 기능을 구현했다. 그리고 USer 클래스에서는 매개변수의 다형성을 활용해 인터페이스를 매개변수로 받도록 정의했다.
이러한 과정을 통해 Provider 클래스의 내용이 변경되더라도 User 클래스는 더이상 코드를 변경하지 않아도 같은 결과를 출력할 수 있게 되었다.

결론적으로 인터페이스는 코드 변경의 번거로움을 최소화하고 손쉽게 해당 기능을 사용할 수 있게 한다. 선언과 구현을 분리시켜 개발시간을 단축할 수 있고 독립적인 프로그래밍을 통해 한 클래스의 변경이 다른 클래스에 미치는 영향을 최소화한다는 장점이 있다.


참고자료

자바 다형성, 참조변수의 형변환, 업캐스팅, 다운캐스팅, instance of

다형성
다형성(컴퓨터 과학)
다형성의 개념
Java - Polymorphism

추상화
추상화(컴퓨터 과학)
Java - Abstraction

이미지 출처

해어린 블로그

0개의 댓글