이제부터는 백엔드 Course를 위해 Java에 대한 기본적인 문법을 정리하려고 한다.
필자는 이전에 자바를 공부했었던 경험이 있기 때문에 개인적으로 중요하다고 느껴지는 개념들만 따로 빼서 정리해보려고 한다.
이번 포스팅에서는 상속
이라는 개념을 살펴보자 👀
상속이라는 개념을 설명하기 위해서는 부모와 자식이라는 개념이 추가된다.
부모 클래스가 생성한 메서드들을 사용하고 싶다면 자식 클래스는 상속 키워드를 사용하여 부모 클래스를 상속하면 된다.
상속에 사용하는 키워드는 2가지가 있는데, extends
와 implements
가 있다.
따라서, 상속은 객체지향 프로그래밍의 가장 중요한 특징 중 하나다.
상속을 통해 우리는 다음과 같은 이점을 얻을 수 있다
상속 장점
- 코드의 중복을 제거할 수 있다
- 다형적 표현이 가능하다
특히 두 번째 특징인 다형성이 왜 중요한지 예제를 통해 살펴보자
다형성은 하나의 객체를 여러 가지 타입으로 표현할 수 있는 특징을 의미한다.
스터디 팀원분께 여쭤봤던 내용이라 특히 더 기억에 남는 것 같다.
우선 아래 예제를 살펴보자
Sample Code
class Fruit { } class Apple extends Fruit { } class Grape extends Fruit { } class Kiwi extends Fruit { }
해당 코드는 Fruit
라는 부모 클래스가 있으며, 자식 클래스로 Apple
, Grape
, Kiwi
가 있다.
세가지 자식 클래스 모두 Fruit
를 extends 키워드로 상속하고 있다.
위 클래스의 인스턴스를 배열로 만들어서 관리한다면 어떻게 해야할까?
Sample Code
Apple[] apples = { new Apple(), new Apple() }; Grape[] grapes = { new Grape(), new Grape() }; Kiwi[] kiwies = { new Kiwi(), new Kiwi() };
이처럼 특정 타입의 클래스만 묶어서 관리할 수 있게 된다.
(자바 배열은 동일한 타입의 데이터만 관리할 수 있다)
하지만, 다형성을 사용해서 조금 더 포괄적으로 데이터를 배열로 관리할 수 있다.
Sample Code
Fruit[] fruits = { new Apple(), new Grape(), new Kiwi() };
해당 예제는 다형성을 이용하여 부모 타입인 Fruit
로 배열을 선언하고 있다.
부모 타입을 상속받은 자식 인스턴스들은 배열로 관리할 수 있게 된다.
즉, 다형성을 활용하면 여러 타입의 객체를 하나의 배열로 관리할 수 있다!
다형성 관련해서 조금 더 살펴보자
다형성을 사용할 때 알아야 할 중요한 내용이 있다.
우선 코드를 한번 살펴보자
Sample Code
class A { int m = 3; void abc() { System.out.println("A"); } } class B extends A { int n = 4; void bcd() { System.out.println("B"); } } public class Test { public static void main(String[] args) { // B 타입으로 선언한 경우 B b = new B(); System.out.println(b.m); // 3 System.out.println(b.n); // 4 b.abc(); // A b.bcd(); // B // A 타입으로 선언한 경우 (다형성) A a = new B(); System.out.println(a.m); // 3 // System.out.println(a.n); // 컴파일 에러 a.abc(); // A // a.bcd(); // 컴파일 에러 } }
- React랑은 다르게 주석으로 결과를 표시해야겠다
코드에서 인스턴스 b를 B 타입으로 선언할 경우, 부모 클래스의 기능과 필드에 모두 접근할 수 있다.
당연히 B 클래스에서 정의한 메서드와 필드도 모두 사용할 수 있다.
반면, 다형성을 이용하여 A 타입으로 생성된 인스턴스 a를 살펴보자
(A a = new B();
)
a의 경우, 자신을 상속받은 B 클래스 내부에서 정의된 내용은 참조할 수 없다.
(부분 집합 개념으로 생각하면 편하다)
따라서, 다형성을 사용할 때는 참조 변수의 타입을 기준으로 접근 가능한 멤버가 결정된다는 것을 기억하자
사실 위 예제만 살펴봤을 때는 굳이 다형성이라는 개념을 사용할 필요가 없어보인다.
왜냐하면 하위 기능을 사용하지 못한다면, 일반적인 객체 생성 방식을 사용하는 것이 더 자연스럽기 때문이다.
그래서 다형성의 가치를 보여줄 수 있는 예시를 가져와봤다.
다형성은 오버라이딩과 같이 사용할 때 의미가 있다.
Sample Code
class Payment { void pay() { System.out.println("결제를 진행합니다."); } } class KakaoPay extends Payment { @Override void pay() { System.out.println("카카오페이로 결제합니다."); } } class NaverPay extends Payment { @Override void pay() { System.out.println("네이버페이로 결제합니다."); } }
class PaymentProcessor { void processPayment(Payment payment) { payment.pay(); } }
public class Test { public static void main(String[] args) { PaymentProcessor processor = new PaymentProcessor(); processor.processPayment(new KakaoPay()); // "카카오페이로 결제합니다." processor.processPayment(new NaverPay()); // "네이버페이로 결제합니다." class TossPay extends Payment { @Override void pay() { System.out.println("토스로 결제합니다."); } } processor.processPayment(new TossPay()); // "토스로 결제합니다." } }
이 예제를 통해 다형성이 활용되는 상황을 이해할 수 있다.
우선 Payment라는 상위 클래스가 있고, 이를 상속받은 KakaoPay와 NaverPay가 있다.
각각의 클래스는 pay() 메서드를 자신만의 방식으로 오버라이딩하고 있다.
여기서 주목할 부분은 PaymentProcessor 클래스인데, processPayment() 메서드는 Payment 타입의 매개변수를 받는다.
다형성을 활용했기 때문에 KakaoPay, NaverPay 등 Payment를 상속받은 어떤 클래스의 인스턴스든 매개변수로 전달할 수 있다.
그리고 실제로 main 메서드를 보면, 서로 다른 결제 수단들이 모두 동일한 processPayment() 메서드를 통해 처리되고 있다.
또한, 나중에 추가된 TossPay도 PaymentProcessor의 수정 없이 바로 사용할 수 있는 모습을 볼 수 있다.
이를통해 다형성을 이용하면 새로운 결제 수단이 추가되더라도 기존 코드를 전혀 수정할 필요가 없다는걸 알 수 있다!
업캐스팅은 자식 클래스의 인스턴스를 부모 클래스의 참조 변수에 대입하는 것을 말한다.
간단한 코드를 살펴보면 다음과 같다.
Sample Code
class Animal { } class Dog extends Animal { } public class Test { public static void main(String[] args) { Dog dog = new Dog(); Animal animal = dog; } }
업캐스팅은 사실 자동으로 형변환이 이루어진다.
다만, 자식 클래스의 고유한 멤버는 접근이 불가능하다는 특징이 있다.
다운 캐스팅은 업 캐스팅의 반대로 부모 타입의 참조 변수를 자식 타입으로 변환하는 것을 말한다.
예시 코드를 살펴보면 다음과 같다.
Sample Code
class Animal { void makeSound() { System.out.println("동물 소리"); } } class Dog extends Animal { void bark() { System.out.println("멍멍!"); } } public class Test { public static void main(String[] args) { Animal animal = new Dog(); // 업 캐스팅 Dog dog = (Dog) animal; // 다운 캐스팅 dog.bark(); // "멍멍!" 출력 } }
이처럼 부모 타입을 자식 타입에 대입하는 경우, 명시적으로 ()
를 사용하여 다운 캐스팅을 수행해야 한다.
위 코드에서 animal은 new Dog()
로 생성되었기 때문에 위처럼 명시적 다운캐스팅이 가능하다.
반면, 다음 코드를 생각해보자
Animal animal2 = new Animal();
// Dog dog2 = (Dog) animal2; // 런타임 에러
이 코드는 new Animal()
로 animal2 인스턴스가 생성되었다.
이러한 경우에는 자식 타입인 Dog로 명시적 다운 캐스팅이 불가하다.
다음 사진을 살펴보자
사진 맨 좌측의 화살표를 보고 상속 구조를 파악할 수 있다.
a 인스턴스를 new B()
키워드로 생성했으니 A와 B의 멤버들이 모두 메모리에 할당된다.
당연히 A와 B의 멤버를 a 인스턴스는 가지고 있기 때문에 B b = (B) a;
다운캐스팅이 가능하다.
반면, C c = (C) a;
를 만족시키기 위해서는 a가 가르키는 메모리 공간에 C 멤버를 가지고 있어야 한다.
하지만, 해당 위치에는 A와 B의 멤버만 가지고 있으므로 다운 캐스팅이 불가하다!
안전한 다운캐스팅을 위해 instanceof 연산자를 사용할 수 있는데 이를 간단하게 살펴보자
Sample Code
public class Test { public static void main(String[] args) { Animal animal = new Dog(); if (animal instanceof Dog) { Dog dog = (Dog) animal; dog.bark(); } Animal animal2 = new Animal(); if (animal2 instanceof Dog) { // False Dog dog2 = (Dog) animal2; } } }
instanceof 연산자는 특정 인스턴스의 타입을 확인하는 연산자이다.
위 코드에서 animal2 instanceof Dog
는 False를 반환한다.
왜냐하면 animal2는 new Animal()
키워드로 생성되었기 때문이다.
이처럼 instanceof 연산자를 사용하면 캐스팅이 가능한 경우에만 코드를 안정적으로 실행할 수 있다!
(타입 체크 연산자라고 생각하면 된다.)
이번 포스팅을 통해서 상속
이라는 개념을 다뤄봈다.
상속이라는 개념에 이어서 다형성
과 캐스팅
이라는 부분까지 정리해볼 수 있었는데, 개념적으로 어려운 부분은 없으니 잘 알아두자 👊