개발하고 싶은 초심자

220830 D+6 상속, 포함 관계, 메소드 오버라이딩, super vs super(), Object 클래스, 객체지향 3요소(캡슐화, 다형성, 추상화) 본문

기술개념정리(in Java)

220830 D+6 상속, 포함 관계, 메소드 오버라이딩, super vs super(), Object 클래스, 객체지향 3요소(캡슐화, 다형성, 추상화)

정새얀 2022. 8. 30. 17:40

1. 상속

: 기존의 클래스를 재활용하여 새로운 클래스를 작성하는 자바의 문법 요소.

→ 두 클래스를 상위 클래스와 하위 클래스로 나누어

상위 클래스의 멤버(필드, 메서드, 이너 클래스)를 하위 클래스와 공유하는 것을 의미한다.

→ 상위 클래스와 하위 클래스를 '서로 상관관계가 있다'라고 하며

하위 클래스는 상위 클래스가 가진 모든 멤버를 상속받게 된다.

⇒ 하위 클래스의 멤버 개수는 언제나 상위 클래스의 그것과 비교했을 때 같거나 많다.

(상속받았다 보다는 ~ 클래스로부터 확장되었다 라는 표현이 더 적절하다)

 

ex)

캐릭터 이미지 출처: 스타듀밸리 공식 카페 '배추한포기'님

→ Programmer, Doctor, Teacher 세 개의 클래스에 공통적인 속성과 기능이 정의되어 있음.

⇒ People 클래스가 상위 클래스,

Programmer, Doctor, Teacher 클래스가 상위 클래스로부터 특정한 속성과 기능이 확장된 하위 클래스.

✷ 상속의 장점 중 하나가 다형성의 표현이며, 이 다형성이 객체지향적 설계를 수행하는 데에 매우 중요한 개념이다.

✷ 단일 상속(single inheritance)만을 허용한다(= 다중 상속이 허용되지 않는다).

 

ex)

class Person {
  String name;
  int age;

  void learn(){
    System.out.println("공부를 합니다.");
  };
  void walk(){
    System.out.println("걷습니다.");
  };
  void eat(){
    System.out.println("밥을 먹습니다.");
  };
}

class Programmer extends Person { // Person 클래스로부터 상속. extends 키워드 사용 
  String companyName;

  void coding() {
    System.out.println("코딩을 합니다.");
  };
}

class Doctor extends Person { // Person 클래스로부터 상속
  String hospitalName;

  void diagnosis() {
    System.out.println("진단을 합니다.");
  };
}

class Teacher extends Person { // Person 클래스로부터 상속
  String schoolName;
  
  void teaching() {
    System.out.println("수학을 가르칩니다.");
  };
}

public class HelloJava {
  public static void main(String[] args) {
    
    // Person 객체 생성
    Person p = new Person();
    p.name = "Harvey";
    p.age = 27;
    p.learn();
    p.eat();
    p.walk();
    System.out.println(p.name);

    //Programmer 객체 생성
    Programmer pg = new Programmer();
    pg.name = "Sebastian";
    pg.age = 24;
    pg.learn(); // Persons 클래스에서 상속받아 사용 가능
    pg.coding(); // Programmer의 개별 기능
    System.out.println(pg.name);
  }
}

//출력값
공부를 합니다.
밥을 먹습니다.
걷습니다.
Harvey
공부를 합니다.
코딩을 합니다.
Sebastian

2. 포함 관계(composite)

‣ 포함: 클래스 멤버로 다른 클래스 타입의 참조 변수를 선언하는 것(클래스를 재사용할 수 있는 방법)

public class Employee {
  int id;
  String name;
  Address address;

  public Employee(int id, String name, Address address) {
    this.id = id;
    this.name = name;
    this.address = address;
  }

  void showInfo() {
    System.out.println(id + " " + name);
    System.out.println(address.city+ " " + address.country);
  }

  public static void main(String[] args) {
    Address address1 = new Address("Pellican City", "Stardew Valley");
    Address address2 = new Address("the Oasis", "Calico Desert");

    Employee e = new Employee(1, "Emily", address1);
    Employee e2 = new Employee(2, "Sandy", address2);

    e.showInfo();
    e2.showInfo();
  }
}

class Address {
  String city, country;

  public Address(String city, String country) {
    this.city = city;
    this.country = country;
  }
}

// 출력값
1 Emily
Pellican City Stardew Valley
2 Sandy
the Oasis Calico Desert

✷ 상속과 포함관계를 구분하는 방법

‣ 상속: ~은 ~이다(IS-A) 관계

‣ 포함관계: ~은 ~을 가지고 있다(HAS-A) 관계

 

3. 메소드 오버라이딩(Method Overriding)

: 상위 클래스로부터 상속받은 메소드와 동일한 이름의 메서드를 재정의하는 것.

class Vehicle {
  void run() {
    System.out.println("Vehicle is running");
  }
}

public class Bike extends Vehicle { // Vehicle 클래스 상속
  void run() {
    System.out.println("Bike is running"); // 메서드 오버라이딩
  }

  public static void main(String[] args) {
    Bike bike = new Bike();
    bike.run();
  }
}

// 출력값
"Bike is running"

→ 메서드 오버라이딩은 Bike 메서드가 Vehicle 클래스로부터 상속받은 run() 메서드를자신에 맞게 변경하는 일과 관련.

✷ 메서드 오버라이딩을 사용할 때의 세 가지 조건

① 메소드의 선언부(메서드 이름, 매개변수, 반환 타입)가 상위 클래스의 그것과 완전히 일치해야 한다.

② 접근 제어자의 범위가 상위 클래스의 메서드보다 같거나 넓어야 한다.

③ 예외는 상위 클래스의 메소드보다 많이 선언할 수 없다.

‣ 메소드 오버라이딩을 사용해야 하는 이유

public class Main {
  public static void main(String[] args) {
    Bike bike = new Bike(); // 각각의 타입으로 선언 + 각각의 타입으로 객체 생성
    Car car = new Car();
    MotorBike motorBike = new MotorBike();
        
	bike.run();
    car.run();
    motorBike.run();

	Vehicle bike2 = new Bike(); // 상위 클래스 타입으로 선언 + 각각 타입으로 객체 생성
    Vehicle car2 = new Car();
    Vehicle motorBike2 = new MotorBike();
    
    bike2.run();
    car2.run();
    motorBike2.run();
  }
}

class Vehicle {
  void run() {
    System.out.println("Vehicle is running");
  }
}

class Bike extends Vehicle {
  void run() {
    System.out.println("Bike is running");
  }
}

class Car extends Vehicle {
  void run() {
    System.out.println("Car is running");
  }
}

class MotorBike extends Vehicle {
  void run() {
    System.out.println("MotorBike is running");
  }
}

// 출력값
Bike is running
Car is running
MotorBike is running

→ Bike, Car, MotorBike라는 세 개의 클래스가 각각 Vehicle 클래스로부터 상속을 받아

run() 메서드를 자신에게 맞는 방식으로 오버라이딩하고 있다.

각각의 객체의 run() 메소드를 실행하면 각 객체에 맞는 run() 메서드가 실행되어

결과값을 반환하고 있는 것을 확인할 수 있다.

 

→ 다형적 표현을 사용하여 똑같이 각각의 객체를 사용하면서 이들 모두를 상위 클래스 타입을 선언하면?

참조 변수 bike2, car2, motorBike2는 모두 Vehicle 타입이지만

메서드 오버라이딩을 통해 각각의 run() 메서드가 다른 출력값을 보여준다는 사실을 확인할 수 있다.

// 배열로 한번에 관리하기

Vehicle[] vehicles = new Vehicle[] { new Bike(), new Car(), new MotorBike()};
for (Vehicle vehicle : vehicles) {
  vehicle.run();
}

// 출력값
Bike is running
Car is running
MotorBike is running

⇒ 모든 객체를 상위 클래스 타입 하나로 선언하면 간편하게 배열로 선언하여 관리할 수 있다는 편리성이 있다.

 

3. super 키워드 / super()

‣ super: 상위 클래스의 객체

‣ super(): 상위 클래스의 생성자를 호출하는 것

⇒ 모두 상위 클래스의 존재를 상정하며 상속 관계를 전제로 한다.

public class Super {
  public static void main(String[] args) {
    Lower l = new Lower();
    l.callNum();
  }
}

class Upper {
  int count = 20; // super.count
}

class Lower extends Upper {
  int count = 15; // this.count
  
  void callNum() {
  
    // 자기에게서 가장 가까운 변수 15
    System.out.println("count = " + count);
    
    // 자신이 호출된 객체의 인스턴스 변수 15
    System.out.println("this.count = " + this.count);
    
    // 상위 클래스의 변수를 참조한 값 20
    System.out.println("super.count = " + super.count);
  }
}

// 출력값
count = 15
count = 15
count = 20

Lower클래스가 Upper클래스로부터 변수 count를 상속받는데,
자신의 인스턴스 변수 count와 이름이 같아 구분하기 위한 방법이 super이다.

‣ super 키워드를 붙이지 않으면 자바 컴파일러는 해당 객체는 자신이 속한 인스턴스의 멤버를 먼저 참조한다.

‣ 상위 클래스의 변수를 참조해야 하는 경우 super 키워드를 사용하여 부모 객체의 멤버 값을 참조할 수 있다.

⇒ 상위 클래스의 멤버와 자신의 멤버를 구별하는 데에 사용된다는 점을 제외하면

this와 super는 기본적으로 같은 것이라고 말할 수 있다.

public class Test {
  public static void main(String[] args) {
    Student s = new Student();
  }
}

class Human {
  Human() {
    System.out.println("휴먼 클래스 생성자");
  }
}

class Student extends Human { // Human 클래스로부터 상속
  Student() {    
    
    // Human 클래스의 생성자 호출
    // 생성자 안에서만 사용 가능하다
    // 반드시 첫 줄에 와야한다
    super(); 
    System.out.println("학생 클래스 생성자");
  }
}

// 출력값
휴먼 클래스 생성자
학생 클래스 생성자

⇒ 모든 생성자의 첫 줄에는 반드시 this() 혹은 super()가 선언되어야 한다.

만약 super()가 없는 경우 컴파일러가 생성자의 첫 줄에 자동으로 super()를 삽입한다.

이때 상위 클래스에 기본 생성자가 없으면 에러가 발생한다.

 

4. Object 클래스

: 자바의 클래스 상속계층도에서 최상위에 위치한 상위 클래스.

→ 자바의 모든 클래스는 Object 클래스로부터 확장된다.

→ Object 클래스의 멤버들을 자동으로 상속받아 사용할 수 있다.

class ParentEx {  //  컴파일러가 "extends Object" 자동 추가 
  
}

class ChildEx extends ParentEx {
  
}

✷ Object 클래스의 대표적인 메서드

메서드명 반환 타입 주요 내용
toString() String 객체 정보를 문자열로 출력
equals(Object obj) boolean 등가 비교 연산(==)과 동일하게 스택 메모리값을 비교
hashCode() int 객체의 위치정보 관련. Hashtable 또는 HashMap에서 동일 객체여부 판단
wait() void 현재 쓰레드 일시정지
notify() void 일시정지 중인 쓰레드 재동작

 

5. 객체지향의 3요소

① 캡슐화(encapsulation)

: 특정 객체 안에 관련된 속성과 기능을 하나의 캡슐로 만들어 데이터를 외부로부터 보호하는 것.

→ 외부로부터 객체의 속성과 기능이 함부로 변경되지 못하게 막고,

데이터가 변경되더라도 다른 객체에 영향을 주지 않기에 독립성을 확보할 수 있다(data hiding / 정보 은닉).

→ 유지보수, 코드 확장 시 오류의 범위를 최소화할 수 있어 효과적으로 코드를 유지보수할 수 있다.

 

‣ 패키지(package)

: 특정한 목적을 공유하는 클래스와 인터페이스의 묶음.

→ 클래스들을 그룹 단위로 묶어 효과적으로 관리하기 위한 목적을 가지고 있음.

→ 하나의 디렉터리, 하나의 패키지에 속한 클래스나 인터페이스 파일은 모두 해당 패키지에 속해 있음.

하나의 계층 구조를 가지고 있고, 계층 구조 간 구분은 점(.)으로 표현된다.

→ 패키지가 있는 경우 소스 코드의 첫 번째 줄에 반드시 package 패키지명이 표시되어야 하고,

만약 패키지 선언이 없으면 이름 없는 패키지에 속하게 된다.

// 패키지를 생성했을 때
package practicepack.test; // 패키지 구문 포함. 패키지가 없다면 구문 필요없음

public class PackageEx {

}

자바에 기본적으로 포함되어있는 대표적인 패키지로

자바의 기본 클래스들을 모아 놓은 java.lang,

확장 클래스를 묶어 놓은 java.util,

자바의 입출력과 관련된 클래스를 묶어놓은 java.io와 java.nio 등이 있다.

 

→ 우리가 주로 사용하는 String 클래스의 실제 이름은 java.lang.String인데,

여기서 java.lang은 패키지명을 나타내고 점(.)을 사용하여 디렉터리 계층구조를 나타내고 있다.

→ 패키지로 클래스를 묶는 것의 또 하나의 장점은 클래스의 충돌을 방지해주는 기능이다.

같은 이름의 클래스를 가지고 있더라도 각각 다른 패키지에 소속되어 있다면 이름명으로 인한 충돌이 발생하지 않는다.

규모가 큰 프로젝트에서 협업 시 클래스명 중복으로 인한 충돌이 종종 발생할 수 있는데

패키지를 설정하면 이러한 클래스 간의 충돌을 효과적으로 방지할 수 있다.

 

‣ Import문

: 다른 패키지 내의 클래스를 사용하기 위해 사용하며,

일반적으로 패키지 구문과 클래스문 사이에 작성한다.

→ 컴파일 시 import문은 처리되기 때문에 프로그램의 성능에 영향을 주지 않는다.

package practicepack.test;

public class ExampleImport {
  public int a = 10;
  public void print() {
  System.out.println("Import문 테스트")
}


package practicepack.test2; // import문을 사용하지 않는 경우, 다른 패키지 클래스 사용방법

public class PackageImp {
  public static void main(String[] args) {
  practicepack.test.ExampleImport example = new practicepack.test.ExampleImport();
  }
}

→ import문을 사용하지 않고 다른 패키지의 클래스를 사용하기 위해

패키지명을 모두 포함시켜서 클래스의 패키지에 대한 정보를 제공해야 한다.

import 패키지명.클래스명;

// 같은 패키지에서 여러 클래스가 사용될 때 작성할 수 있는 방법
import 패키지명.*;

→ 먼저 import 키워드를 써주고 패키지명과 패키지명을 생략하고자 하는 클래스명을 함께 써준다.

package practicepack.test;
public class ExampleImp {
  public int a = 10;
  public void print() {
  System.out.println("Import문 테스트")
}

package practicepack.test2; // import문을 사용하는 경우

import practicepack.test.ExampleImp // import문 작성

public class PackageImp {
  public static void main(String[] args) {
    ExampleImp x = new ExampleImp(); // 이제 패키지명을 생략 가능
  }
}

‣ 접근 제어자

제어자(Modifier)

: 클래스, 필드, 메서드, 생성자 등에 부가적인 의미를 부여하는 키워드.

→ 접근 제어자: public, private, protected, default

접근 제어자 클래스 내 패키지 내 다른 패키지의 하위 클래스 패키지 외
Private O X X X
Public O O O O
Protected O O O X
Default O O X X

public(접근 제한 없음) > protected(동일 패키지 + 하위 클래스) > default(동일 패키지) > private(동일 클래스)

순으로 접근 제한 번위에 따라 정리할 수 있다.

✷ default

: 아무런 접근 제어자를 붙이지 않는 경우 기본적인 설정으로,

변수명 앞에 아무런 접근 제어자가 없는 경우 자동으로 해당 변수의 접근 제어자가 default가 된다.

package package1; // 패키지명 package1 

// 파일명: Parent.java
class Test { // Test 클래스의 접근 제어자는 default
  public static void main(String[] args) {
    Parent p = new Parent();
    
    // private 접근 제어자가 있는 a는 접근 불가, 동일 클래스가 아니기 때문에 에러발생!
    // System.out.println(p.a); 
    System.out.println(p.b);
    System.out.println(p.c);
    System.out.println(p.d);
  }
}

public class Parent { // Parent 클래스의 접근 제어자는 public
  private int a = 1; // a,b,c,d에 각각 private, default, protected, public 접근제어자 지정
  int b = 2;
  protected int c = 3;
  public int d = 4;

  public void printEach() { // 동일 클래스이기 때문에 에러 발생하지 않음
    System.out.println(a);
    System.out.println(b);
    System.out.println(c);
    System.out.println(d);
  }
}

// 출력값
2
3
4
package package2; // package2 

// 파일명 Test2.java
import package1.Parent;

class Child extends package1.Parent {  // package1으로부터 Parent 클래스를 상속
  public void printEach() {
  
    // private제어자를 사용하는 멤버에 접근 불가능, 에러 발생
    // System.out.println(a);
    
    // default제어자를 사용하는 멤버에 접근 불가능, 에러 발생
    // System.out.println(b);
    
    // 다른 패키지의 하위 클래스
    System.out.println(c); 
    System.out.println(d);
  }
}

public class Test2 {
  public static void main(String[] args) {
    Parent p = new Parent();
    
    // public을 제외한 모든 호출 에러
    // Test2가 상속받은 클래스가 아니기 때문에 public(d)에만 접근 가능
    // System.out.println(p.a); 
    // System.out.println(p.b);
    // System.out.println(p.c);
    System.out.println(p.d);
  }
}

⇒ 접근 제어자를 통해 외부로부터 데이터를 보호하고, 불필요하게 데이터가 노출되는 것을 방지할 수 있다.

 

→ 기타 제어자: static, final, abstract, native, transient, snychronized 등

하나의 대상에 대해 여러 제어자를 사용할 수 있으나 각 대상에 대해서 접근 제어자는 단 한 번만 사용할 수 있다.

 

‣ Getter / Setter

→ private 접근 제어자가 포함되어 있는 객체의 변수의 데이터 값을 추가하거나 수정하고 싶을 때 사용할 수 있다.

① Setter

: 메서드를 통해 데이터를 변경할 때 사용하는 메서드.

외부에서 메서드에 접근하여 조건에 맞으면 데이터 값을 변경 가능하게 해 주고 메서드명에 set-을 붙여 정의한다.

 

② Getter

: 메서드를 사용해서 외부에서 객체의 데이터를 읽을 때 사용하는 메서드.

설정한 변수 값을 읽어오는 데 사용하는 메서드.

→ 경우에 따라 객체 외부에서 필드 값을 사용하기에 부적절한 경우 그 값을 가공한 이후에 외부로 전달하는 역할을 함.

 

→ 각 필드마다 Getter와 Setter를 작성한다.

생성자 블록에서 필드 값을 다룰 때도 이러한 메서드를 사용하기도 한다.

public class GetterSetterTest {
  public static void main(String[] args) {
    Worker w = new Worker();
    w.setName("Emily");
    w.setAge(25);
    w.setId(3);

    String name = w.getName();
    System.out.println("근로자의 이름은 " + name);
    int age = w.getAge();
    System.out.println("근로자의 나이는 " + age);
    int id = w.getId();
    System.out.println("근로자의 ID는 " + id);
  }
}

class Worker {
  private String name; // 변수의 은닉화. 외부로부터 접근 불가
  private int age;
  private int id;

  public String getName() { // 멤버변수의 값 
    return name;
  }

  public void setName(String name) { // 멤버변수의 값 변경
    this.name = name;
  }

  public int getAge() {
    return age;
  }

  public void setAge(int age) {
    if(age < 1) return;
    this.age = age;
  }

  public int getId() {
    return id;
  }

  public void setId(int id) {
    this.id = id;
  }
}

// 출력값
근로자의 이름은 Emily
근로자의 나이는 25
근로자의 ID는 3

데이터를 효과적으로 보호하면서도 의도하는 값으로 값을 변경하여 캡슐화를 보다 효과적으로 달성할 수 있다.

 

→ 생성자 파라미터에 직접적으로 매개 값을 넣지 않고,

기본 생성자로 우선 객체를 만든 다음 SetterSetter메서드로 매개 값을 전달하여, 데이터를 변경하는 방법을 사용하기도 한다.

ex) 생성자에 파라미터를 넣지 않고도 기본 생성자를 선언하고

Setter 메서드를 사용함으로써 필요한 데이터를 변경, 전달하는 방법

// UserData.java
package Data;

public class UserData {
  private String name;
  private String age;

  public UserData(){        
} //클래스 기본 생성자. 클래스가 인스턴스 되면 실행된다. 

  //Setter, Getter 메소드 입니다. 
  //클래스 내부의 private 변수나 메소드에 접근하기 위해 사용합니다. 
  public void SetName(String name){
    this.name = name;
  } // name 변수에 값을 적용한다. 
  
  public void SetAge(String age){
    this.age = age;
  } // age 변수에 값을 적용한다. 

  public String GetName(){
    return this.name;
  } // 객체에 name 변수를 리턴한다. 

  public String GetAge(){
    return this.age;
  } // 객체에 age 변수를 리턴한다.
  
  // 메소드를 호출하면 객체의 age 변수에 1일 더하는 메소드. 
  public void AddAge(){
    this.age = this.age + 1;
  }
}
// UserDataExample.java
public class UserDataExample {
  public static void main() {
    UserData data = new UserData();
    data.SetName("Haley"); //this.name = "Haley"
    data.SetAge(25); // this.age = 25
  } // UserData data = new UserData("Haley", 25);
}

② 다형성(polymorphism)

: 하나의 객체가 여러 가지 형태를 가질 수 있는 성질.

→ 상위 클래스 타입의 참조 변수를 통해 하위 클래스의 객체를 참조할 수 있도록 허용한 것.

// 참조변수의 다형성 예시
class Villager {
  public void villagerInfo() {
  System.out.println("마을 주민입니다.");
  }
}

class Pellican extends Villagers { 
  public void villagerInfo() {
    System.out.println("나는 Pellican City 주민입니다.");
  }
}

class Calico extends Friend { 
  public void villagerInfo() {
    System.out.println("나는 Calico Desert 주민입니다.");
  }
}

public class VillagerTest {
  public static void main(String[] args) {
    Villager villager = new Villager(); // 객체 타입과 참조변수 타입의 일치
    Pellican pellican = new Pellican();
    Villager calico = new Calico(); // 객체 타입과 참조변수 타입의 불일치
    // 상위 클래스를 참조변수의 타입으로 지정했기 때문에 
    // 자연스럽게 참조변수가 사용할 수 있는 멤버의 개수는 상위 클래스의 멤버의 수가 된다.
    
    villager.villagerInfo();
    pellican.villagerInfo();
    calico.villagerInfo();
  }
}

// 출력값
마을 주민입니다.
나는 Pellican City 주민입니다.
나는 Calico Desert 주민입니다.

→ 상위 클래스의 타입으로 하위 클래스 타입의 객체를 참조하는 것은 가능하지만 그 반대는 성립되지 않는다.

public class VillagerTest {
  public static void main(String[] args) {
    Villager villager = new Villager(); // 객체 타입과 참조변수 타입의 일치 → 가능
    Pellican pellican = new Pellican();
    Villager calico = new Calico(); // // 객체 타입과 참조변수 타입의 불일치 → 가능
    // Calico villager1 = new Villager(); → 하위클래스 타입으로 상위클래스 객체 참조 → 불가능

    villager.villagerInfo();
    pellican.villagerInfo();
    calico.villagerInfo();
  }
}

→ 실제 객체인 Villager의 멤버 개수보다 참조 변수 villager1이 사용할 수 있는 멤버 개수가 더 많기 때문에

하위 클래스의 타입으로 상위 클래스 타입의 객체를 참조하는 것이 성립할 수 없다.

⇒ 실제 참조하고 있는 인스턴스의 멤버를 기준으로

참조 변수의 타입의 멤버가 실제 인스턴스의 멤버 수보다 작은 것은 실제 사용할 수 있는 기능을 줄이는 것이라 허용되지만,

그 반대의 경우는 참조하고 있는 인스턴스에 실제로 구현된 기능이 없어 사용이 불가하기 때문이다.

 

→ 메서드 오버라이딩과 메소드 오버로딩이 둘 다 같은 이름의 메서드를 재사용 또는 덮어쓰기로 다르게 사용한다는 점으로 "하나의 객체가 여러 가지 형태를 가질 수 있는 성질"이라 정의할 수 있었던 다형성의 의미와 같다.

 

ex)

// 음료의 가격 정보를 담고 있는 Beverage 클래스
class Beverage {
  int price;
  
  public Cafe(int price) {
    this.price = price;
  }
}

// Beverage 클래스를 상속받는 Lemonade 클래스와 Einspener 클래스
class Lemonade extends Beverage {};
class Einspener extends Beverage {};

// 음료를 구매하는 손님 Customer 클래스
// 기본적으로 50000원의 돈을 가지고 있다는 가정의 코드
class Customer {
  int money = 50000;
}
// 레몬에이드 한 잔과 아인슈페너 한 잔을 구입하는 기능을 하는 메소드
// Customer 클래스에 추가해볼 수 있을 듯?
void buyBeverage(Lemonade lemonade) { // 레몬에이드 구입
  money = money - lemonade.price;
}

void buyBeverage(Einspener einspener) { // 아인슈페너 구입
  money = money - einspener.price;
}
// 위의 코드를 이렇게 객체의 다형성을 활용하여 새로운 타입을 매개변수로 전달해주는 메소드를 여러 번 사용하지 않아도 된다
void buyBeverage(Beverage beverage) { // 매개변수의 다형성
  money = money - beverage.price;
}

⇒ 다형성이 가지는 특성에 따라 매개변수로 상위 클래스인 Beverage의 타입을 매개변수로 전달받으면

그 하위 클래스 타입의 참조 변수면 어느 것이나 매개변수로 전달될 수 있고

매번 다른 타입의 참조 변수를 매개변수로 전달해주어야 하는 번거로움을 훨씬 줄일 수 있다.

// 최종 코드
package package2;

public class PolymorphismEx {
  public static void main(String[] args) {
    Customer customer = new Customer();
    customer.buyBeverage(new Lemonade());
    customer.buyBeverage(new Einspener());
    
    System.out.println("현재 잔액은 " + customer.money + "원 입니다.");
  }
}

class Beverage {
  int price;

  public Beverage(int price) {
    this.price = price;
  }
}

class Lemonade extends Beverage {
  public Lemonade() {
    super(3500); // 상위 클래스 Cafe의 생성자를 호출
  }

  public String toString() {
    return "레몬에이드";
  } // Object클래스 toString()메소드 오버라이딩
};

class Einspener extends Beverage {
  public Einspener() {
    super(3900);
  }
  
  public String toString() {
    return "아인슈페너";
  }
};

class Customer {
  int money = 50000;

  void buyBeverage(Beverage beverage) {
    if (money < beverage.price) { // 물건 가격보다 돈이 없는 경우
      System.out.println("잔액이 부족합니다.");
      return;
    }
    money = money - beverage.price; // 가진 돈 - 커피 가격
    System.out.println(beverage + "를 구입했습니다.");
  }
}

// 출력값
레몬에이드를 구입했습니다.
아인슈페너를 구입했습니다.
현재 잔액은 42600원 입니다.

→ 다형성을 활용하면 중복되는 많은 코드를 줄이고 보다 편리하게 코드를 작성하는 것이 가능해진다.

③ 추상화(abstraction)

: 기존 클래스들의 공통적인 요소를 뽑아 상위 클래스를 만들어내는 것.

→ 객체의 공통적인 속성과 기능을 추출하여 정의하는 것.

캐릭터 이미지 출처: 스타듀밸리 공식 카페 '배추한포기'님

→ 공통적인 속성과 기능을 모아 정의하면

코드의 중복을 줄이고 보다 효과적으로 클래스 간의 관계를 설정할 수 있으며 유지/보수가 용이해진다.

 

‣ abstract 제어자

: 클래스와 메서드를 형용하는 키워드로,

메소드 앞에 붙은 경우를 추상 메서드, 클래스 앞에 붙은 경우를 추상 클래스라고 부른다.

→ 어떤 클래스에 추상 메서드가 포함되어 있는 경우 해당 클래스는 자동으로 추상 클래스가 된다.

ex)

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

→ 추상 메소드

: 메서드의 시그니처만 있고 바디가 없는 메서드.

abstract 키워드를 메서드 이름 앞에 붙여 해당 메서드가 추상 메서드임을 표시한다.

⇒ 충분히 구체화되지 않은 '미완성 메서드'이며,

미완성 메서드를 포함하는 클래스는 '미완성 클래스'를 의미하는 추상 클래스가 된다.

AbstractExample abstractExample = new AbstractExample(); // 에러발생. 

 

‣ 추상 클래스

: 추상 메소드를 포함하는 미완성 설계도.

→ 추상 클래스는 미완성 설계도이기 때문에 메소드 바디가 완성되기 전까지 이를 기반으로 객체 생성이 불가능하다.

✷ 미완성 클래스를 만드는 이유

① 추상 클래스는 상속 관계에 있어 새로운 클래스를 작성하는데 매우 유용하다.

→ 메서드의 내용이 상속을 받는 클래스에 따라서 종종 달라지기 때문에

상위 클래스에서는 선언 부만을 작성하고, 실제 구체적인 내용은 상속을 받는 하위 클래스에서 구현하도록 비워둔다면

설계하는 상황이 변하더라도 보다 유연하게 대응할 수 있다. 이때 사용하게 되는 것이 오버라이딩이다.

abstract class Animal {
  public String kind;
  public abstract void sound();
}

class Dog extends Animal { // Animal 클래스로부터 상속
  public Dog() {
    this.kind = "포유류";
  }
  
  public void sound() { // 메소드 오버라이딩 -> 구현부 완성
    System.out.println("멍멍");
  }
}

class Cat extends Animal { // Animal 클래스로부터 상속
  public Cat() {
    this.kind = "포유류";
  }
  
  public void sound() { // 메소드 오버라이딩 -> 구현부 완성
    System.out.println("야옹");
  }
}

class DogExample {       
  public static void main(String[] args) throws Exception {
    Animal dog = new Dog();
    dog.sound();
    
    Cat cat = new Cat();
    cat.sound();
  }
 }

// 출력값
멍멍
야옹

→ 동물이 가지는 공통적인 특성을 모아 먼저 추상 클래스로 선언해주었고, 
이를 기반으로 각각의 상속된 하위 클래스에서 오버라이딩을 통해 클래스의 구체적인 내용을 결정해준다.

→ 먼저 Animal 클래스 안에 abstract 키워드를 사용한 sound() 메서드가 추상 메소드로 선언되었고,

이를 포함하는 Animal 클래스 또한 abstract 키워드를 사용하여 추상 클래스로 만들어주었다.

이후 추상 클래스 Animal를 상속받은 Dog 클래스와 Cat 클래스 안에

추상 메소드 sound()를 각각 오버라이딩하여 각 객체에 맞는 구현부를 완성해주었고,

마지막으로 이렇게 완성된 클래스를 기반으로 dog 인스턴스와 cat 인스턴스를 생성하여 sound() 메서드를 호출했다.

⇒ 추상 클래스를 사용하면 상속을 받는 하위 클래스에서 오버라이딩을 통해 각각 상황에 맞는 메서드 구현이 가능하다.

 

② 추상 클래스는 추상화를 구현하는데 핵심적인 역할을 수행한다.

→ 여러 사람이 함께 개발하는 경우 공통된 속성과 기능임에도 불구하고

각각 다른 변수와 메서드로 정의되는 경우 발생할 수 있는 오류를 미연에 방지할 수 있다.

상속계층도의 상층부에 위치할수록 추상화의 정도가 높고 그 아래로 내려갈수록 구체화된다.

(= 상층부에 가까울수록 더 공통적인 속성과 기능들이 정의되어 있다)

 

‣ final 키워드

: 필드, 지역 변수, 클래스 앞에 위치할 수 있으며 위치에 따라 의미가 조금씩 달라진다.

위치 의미
클래스 변경 또는 확장 불가능한 클래스, 상속 불가
메서드 오버라이딩 불가
변수 값 변경이 불가한 상수

⇒ 공통적으로 변경이 불가능하고 확장할 수 없다

final class FinalEx { // 확장/상속 불가능한 클래스
  final int x = 1; // 변경되지 않는 상수
  
  final int getNum() { // 오버라이딩 불가한 메서드
    final int localVar = x; // 상수
      return x;
  }
}

⇒ 각각의 클래스, 메서드, 변수 앞에 final 제어자가 추가되면

해당 대상은 더 이상 변경이 불가하거나 확장하지 않는 성질을 지닌다.

 

‣ 인터페이스(interface)

: 서로 다른 두 시스템, 장치, 소프트웨어를 서로 이어주는 부분 또는 그런 접속 장치.

ex) GUI(Graphic User Interface) / Windows, Mac OS

 

✷ 추상 클래스 vs 인터페이스

추상 클래스: 설계가 끝나지 않은 미완성 설계도

→ 메소드 바디가 없는 추상 메서드를 하나 이상 포함한다는 점 외에는 기본적으로 일반 클래스와 동일하다.

 

인터페이스: 추상 클래스보다 더 높은 추상성을 가지는 가장 기초적인 밑그림

→ 기본적으로 추상 메서드와 상수만 멤버로 가질 수 있음

(추상 클래스에 비해 추상화 정도가 높음)

 

① 인터페이스의 기본 구조

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로 정의된다

→ static, default 메소드 이외의 모든 메서드(인터페이스 안에 메서드를 정의하는 경우)가 public abstract로 정의된다

(모든 인터페이스의 필드와 메서드에는 위의 요소가 포함되어 있기 때문에 따로 명시하지 않아도 생략 가능,

생략된 부분은 컴파일러가 자동으로 추가해주게 된다)

 

② 인터페이스의 구현

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

→ 특정 인터페이스를 구현한 클래스는 해당 인터페이스에 정의된 모든 추상 메서드를 구현해야 한다.

⇒ 어떤 클래스가 특정 인터페이스를 구현한다는 것은

그 인터페이스가 가진 모든 추상 메서드들을 해당 클래스 내에서 오버라이딩하여 바디를 완성한다는 의미를 가진다.

(= 그 클래스에게 인터페이스의 추상 메서드를 반드시 구현하도록 강제하는 것)

 

③ 인터페이스의 다중 구현

: 인터페이스는 다중 구현이 가능하다.

→ 하나의 클래스가 여러 개의 인터페이스를 구현할 수 있다.

다만 인터페이스는 인터페이스로부터만 상속이 가능하다.

클래스와 달리 Object 클래스 같은 최고 조상은 존재하지 않는다.

class ExampleClass implements ExampleInterface1, ExampleInterface2, ExampleInterface3 { 
  ... 생략 ...
}
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();
  }
}

// 출력값
멍멍!
원반 던지기
야옹~!
쥐 잡기

→ 인터페이스는 애초에 미완성된 멤버를 가지고 있기 때문에 충돌 발생 여지가 없어 안전하게 다중 구현이 가능하다.

(클래스에서 다중 상속이 불가능한 이유는 만약 부모 클래스에 동일한 이름의 필드 / 메서드가 존재하는 경우 충돌이 발생함)

 

→ 특정 클래스는 다른 클래스로부터의 상속을 받으면서 동시에 인터페이스를 구현할 수 있다.

abstract class Animal { // 추상 클래스
  public abstract void cry();
} 
interface Pet { // 인터페이스
  public abstract void play();
}

class Dog extends Animal implements Pet { // Animal 클래스 상속 & Pet 인터페이스 구현
  public void cry(){
    System.out.println("멍멍!");
  }

  public void play(){
    System.out.println("원반 던지기");
  }
}

class Cat extends Animal implements 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();
  }
}

// 출력값
멍멍!
원반 던지기
야옹~!
쥐 잡기

→ 기존의 Animal 인터페이스를 추상 클래스로 바꾸고 
Animal 상위 클래스로부터 Dog 과 Cat 클래스로 확장되는 것과 동시에 
Pet 인터페이스를 구현하도록 하여 같은 결과물이 출력되게 했다

④ 인터페이스의 장점

// Provider 클래스에 의존하고 있는 User 클래스

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("Emily");
  }
}

// 출력값
Emily

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

 

‣ User 클래스가 의존하고 있는 Provider 클래스에 변경 사항이 발생해서

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("Haley");
    }
}

// 출력값
Haley

→ 원래 Provider 클래스에 의존했던 User 클래스의 의존관계를 Provider2 클래스로 변경하기 위해

Provider2 객체를 새롭게 생성해주고, User 클래스의 callProvider 메서드가 동일한 타입의 매개변수를 받을 수 있도록

매개변수의 타입을 Provider2로 변경해주었다.

⇒ Provider 클래스에 의존하고 있는 User 클래스의 코드의 변경이 불가피하다.

 

⇒ 인터페이스의 가장 큰 장점은 일반적인 인터페이스의 기능처럼 역할과 구현을 분리시켜

사용자 입장에서는 복잡한 구현의 내용 또는 변경과 상관없이 해당 기능을 사용할 수 있다는 점이므로,

하나하나 코드를 일일이 변경해주어야 할 필요가 없어진다.

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

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

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

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

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

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

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

// 구체적인 구현체가 아닌 인터페이스를 매개변수로 받도록 정의
class User {
  public void callProvider(Cover cover) { // 매개변수의 다형성 활용
    cover.call();
  }
}

// 각각의 구현체에 implements 키워드로 각각의 기능을 구현함
class Provider implements Cover {
  public void call() {
    System.out.println("Emily");
  }
}

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

// 출력값
Haley

→ Provider 클래스의 내용 변경 또는 교체가 발생하더라도 User 클래스는 더이상 코드를 변경해주지 않아도 같은 결과를 출력

 

→ 기존의 Provider 클래스에 인터페이스라는 껍데기를 씌운 형태로,

User 클래스는 더 이상 Provider의 교체 또는 내용의 변경에 상관없이

인터페이스와의 상호 작용을 통해 의도한 목적을 달성할 수 있다.

 

⇒ 인터페이스는 기능이 가지는 역할과 구현을 분리시켜 사용자로 복잡한 기능의 구현이나 교체/변경을 신경 쓰지 않고도

코드 변경의 번거로움을 최소화하고 손쉽게 해당 기능을 사용할 수 있도록 한다.

⇒ 기능을 구현하는 개발자의 입장에서도 선언과 구현을 분리시켜 개발 시간을 단축할 수 있고,

독립적인 프로그래밍을 통해 한 클래스의 변경이 다른 클래스에 미치는 영향을 최소화할 수 있다는 큰 장점이 있다.

 

✷ 인터페이스 활용 예제

카페를 운영하는 사람이 있습니다. 
단골손님들은 매일 마시는 음료가 정해져 있습니다.
단골손님A는 항상 아이스 아메리카노를 주문합니다. 
단골손님B는 매일 아침 딸기라떼를 구매합니다.
// 카페 손님
public class CafeCustomer {
  public String CafeCustomerName;

  public void setCafeCustomerName(String CafeCustomerName) {
    this.CafeCustomerName = cafeCustomerName;
  }
}

// CafeCustomer 클래스로부터 단골 손님A와 단골 손님B 상속
public class CafeCustomerA extends CafeCustomer {
  
}

public class CafeCustomerB extends CafeCustomer {
  
}

// 카페 사장님 
public class CafeOwner {
  public void giveItem(CafeCustomerB cafeCustomerB) {
    System.out.println("give a glass of strawberry latte to CafeCustomer B");
  }

  public void giveItem(CafeCustomerA cafeCustomerA) {
    System.out.println("give a glass of iced americano to CafeCustomer A");
  }
}

// 메뉴 주문
public class OrderExample {
  public static void main(String[] args) throws Exception {
    CafeOwner cafeowner = new CafeOwner();
    CafeCustomerA a = new CafeCustomerA();
    CafeCustomerB b = new cafeCustomerB();

    cafeowner.giveItem(a);
    cafeowner.giveItem(b);
  }
}

// 출력값
give a glass of iced americano to Client A
give a glass of strawberry latte to Client B

 

단골손님 A와 단골손님 B는 CafeCustomer 클래스로부터 확장되었고, 카페 주인은 CafeOwner 클래스로 정의했다.

그리고 단골손님 A와 B가 올 때 메서드 오버로딩을 사용하여 giveItem 메서드를 호출하고,

OrderExample 클래스에서 객체를 생성하여 실행시키면 출력값과 같은 메시지가 반환되고 있다.


만약 단골손님이 두 명이 아니라 계속 늘어나면?

매번 CafeOwner는 오버로딩한 메서드를 만들어야 하기 때문에 매우 번거로워질 수 있다.

이런 경우에 인터페이스를 활용할 수 있다.

 

‣ 인터페이스를 사용하여 코드를 작성하는 예제
① class 키워드 대신 interface 키워드를 사용하여 Customer 인터페이스를 생성한다.

→ 인터페이스는 상수와 추상 메서드만을 구성 멤버로 가진다.

public interface Customer {
	// 상수
	// 추상 메서드
}

 

implements 키워드를 사용하여

Customer 인터페이스를 각각 구현한 CafeCustomerA와 CafeCustomerB를 정의한다.

public class CafeCustomerA implements Customer {
  
}

public class CafeCustomerB implements Customer {
  
}

인터페이스 없이 작성한 코드에서는 손님의 수가 늘어날수록
구현 클래스를 계속 추가하여 만들어줘야 하는 번거로움이 존재했지만,
인터페이스와 앞서 학습한 참조 변수의 형 변환을 사용하면 Customer 타입을 매개변수로 전달함으로써
추가적인 손님이 등장할 때마다 매번 새롭게 메서드를 작성해야 하는 번거로움을 없앨 수 있다.

// 기존 코드 
public class CafeOwner {
  public void giveItem(CafeCustomerB cafeCustomerB) {
    System.out.println("give a glass of strawberry latte to CafeCustomer B");
  }

  public void giveItem(CafeCustomerA cafeCustomerA) {
    System.out.println("give a glass of iced americano to CafeCustomer A");
  }
}

// 인터페이스를 활용하여 작성한 코드
public class CafeOwner {
  public void giveItem(Customer customer) {
    System.out.println("??????????");
  }
}

 

하지만 현재 작성된 코드로는 각 단골손님이 주문한 내용을 개별적으로 주문하기가 어렵다.

③ 기존의 인터페이스에 getOrder라는 추상 메서드를 인터페이스 Customer에 추가, 이를 활용하여 코드를 재작성

// Customer 인터페이스에 불완전하게 정의되어있는 getOrder() 메서드가 각각의 객체에 맞게 구현부에서 정의되고 있다.
public interface Customer {
  public abstract String getOrder();
}

public class CafeCustomerA implements Customer {
  public String getOrder(){
		return "a glass of iced americano";
	}
}

public class CafeCustomerB implements Customer {
  public String getOrder(){
		return "a glass of strawberry latte";
	}
}

CafeOwner 클래스를 재정의하여 매개변수로 Customer 타입이 입력될 수 있게끔 만들어주면,
매개변수의 다형성에 의해 Customer를 통해 구현된 객체 모두 들어올 수 있다.

public class CafeOwner {
  public void giveItem(Customer customer) {
    System.out.println("Item : " + customer.getOrder());
  }
}
public class OrderExample {
    public static void main(String[] args) throws Exception {
        CafeOwner cafeowner = new CafeOwner();
        Customer cafeCustomerA = new CafeCustomerA();
        Customer cafeCustomerB = new CafeCustomerB();

        cafeowner.giveItem(cafeCustomerA);
        cafeowner.giveItem(cafeCustomerB);
    }
}

// 출력값
Item : a glass of iced americano
Item : a glass of strawberry latte
// 최종 코드
interface Customer {
  String getOrder();
}

class CafeCustomerA implements Customer {
  public String getOrder(){
		return "a glass of iced americano";
	}
}

class CafeCustomerB implements Customer {
  public String getOrder(){
		return "a glass of strawberry latte";
	}
}

class CafeOwner {
  public void giveItem(Customer customer) {
    System.out.println("Item : " + customer.getOrder());
  }
}

public class OrderExample {
    public static void main(String[] args) throws Exception {
        CafeOwner cafeowner = new CafeOwner();
        Customer cafeCustomerA = new CafeCustomerA();
        Customer cafeCustomerB = new CafeCustomerB();

        cafeowner.giveItem(cafeCustomerA);
        cafeowner.giveItem(cafeCustomerB);
    }
}

// 출력값
Item : a glass of iced americano
Item : a glass of strawberry latte

 

최초에 인터페이스를 사용하지 않았을 때 손님의 수만큼 giveItem() 메서드가 필요했던 CafeOwner 클래스가

Customer 인터페이스 사용 후에 단 한 개의 giveItem 메서드로 구현이 가능해졌다.

⇒ 메서드의 개수가 줄었다는 것보다는

CafeOwner 클래스가 더 이상 손님에게 의존적인 클래스가 아닌 독립적인 기능을 수행하는 클래스가 되었다는 점이 중요함.

Comments