객체지향 in Java

박기범·2021년 12월 28일
0

단기간 Java 정복

목록 보기
1/3

본 게시글에서는 단순히 객체지향의 원론적 특성 및 정의를 모두 서술하지는 않는다.
그 대신 Java가 객체지향을 표현하는 방식에 대해 공부하며 알게된 사실들을 Java 문법 중심으로 정리한다.

1. 상속

상속이란 기존의 클래스를 재사용하여 새로운 클래스를 작성하는 방법이며, 이 때 각각의 클래스는 부모 클래스와 자식 클래스의 관계를 맺는다. 상속을 이용할 경우, 자식 클래스는 부모 클래스의 모든 멤버를 갖게된다. 이는 기본적으로 다음과 같은 문법으로 사용 가능하다. (extands 키워드로 상속관계를 맺을 수 있다.)


class Product{
    int price;
    int bonuspoint;

    Product(){
        price = 1000;
        bonuspoint = 10;
    }
    Product(int price, int bonuspoint){
        this.price = price;
        this.bonuspoint = bonuspoint;
    }
}

class Phone extends Product{
    Phone(int price, int bonuspoint){
        super(price, bonuspoint);
    }
}

위의 코드에서 Phone 클래스는 Product 클래스의 인스턴스 멤버들을 그대로 가지게된다.

주목할 것은 thissuper이다. this는 클래스 자기 자신을 가리키고 있으며, 지역 변수와 클래스 멤버 변수를 구별하기 위해 사용한다. Product 클래스의 오버로딩 된 생성자를 보라. 해당 생성자의 매개변수로 사용된 지역변수의 이름와 Product 클래스의 인스턴스 멤버변수의 이름이 동일하다. 이 때 this를 활용해 해당 변수가 어떤 변수인지 구분할 수 있게 된다.

super는 상속 받은 부모 클래스를 가리킨다. Product의 자식 클래스인 Phone에서 super(...) 를 호출한 것이 보이는가? 이는 부모 클래스의 생성자를 의미한다. int형 지역변수 두 개를 매개변수로 넘겼으므로, Product 클래스의 생성자들 중 int형 매개변수가 필요한 생성자가 호출된다.

Java에서는 C++과 다르게 단일 상속만 지원된다. 즉, 멤버가 가장 많이 겹치는 클래스를 하나 상속하고, 나머지 클래스는 포함관계로 표현할 수 있다.

Java에는 Object 클래스가 존재한다. 이는 모든 클래스의 최고 조상 클래스이다. 즉, 부모 클래스가 없는 클래스는 자동으로 Object 클래스를 상속받게 되는데, 이를 통해 toString() 등의 총 11개에 해당하는 클래스 메소드를 가지게 된다.

2. 오버라이딩

부모 클래스로부터 상속받은 메소드를 자식 클래스에서 재정의하는 것을 오버라이딩이라 부른다. 오버라이딩을 위해서는 선언부가 동일해야한다. 즉, 함수 이름, 리턴 타입, 매개변수가 모두 동일하고 구현부만 달라야한다.

여기서 오버로딩과 차이점이 있는데, 오버로딩은 매개변수가 달라야한다는 점이다. (함수의 리턴 타입은 오버로딩을 결정하는데 아무런 영향이 없다.)

또한, 오버라이딩 시 부모 클래스의 접근 제어 범위를 좁힐 수 없다. 접근 제어 범위란, public, protected, default, private에 해당하는 접근 제어자를 통해 제어할 수 있는 클래스 멤버를 공개 범위를 뜻하는데, 이에 대해서는 후술한다.

다음의 예제 코드를 보자.

class Parent{
	
    void parentMethod(){}
    
}

class Child extends Parent(){
	
    void parentMethod(){} // 오버라이딩
    void parnetMethod(int i){}  // 오버로딩
    
    void childMethod(){}
    void childMethod(int i){} // 오버로딩
    
    void childMethod(){} // 에러 : 중복정의
	
}

위의 예제를 통해 오버라이딩과 오버로딩의 차이점과 특색을 볼 수 있다.

3. 제어자

클래스, 메소드, 변수의 선언부에 사용되어 부가적인 의미를 부여한다.

제어자는 크게 두가지로 분류되는데, 접근 제어자와 그 외의 제어자로 나뉜다.

우선 접근제어자부터 설명한다.

접근제어자

접근제어자는 private, default, protected, public의 4가지로 나뉜다. 이들은 각각 다음의 접근 범위를 부여한다.

범위\제어자privatedefaultprotectedpublic
클래스 내부OOOO
패키지 내부XOOO
자식 클래스XXOO
전체 범위XXXO

이와 같은 접근 제어자를 활용해 singleton 패턴을 구현할 수 있다. singleton 패턴은 하나의 클래스에 대한 인스턴스를 하나만 유지하도록 하는 패턴이다. 그 구현은 생성자에 접근제어자를 사용하여 수행할 수 있다.

class Practice{

    public static void main(String arg[]){
    
    	Singleton s = Singleton.getInstance();
        // public으로 공개된 메소드를 통해서만 인스턴스를 리턴받을 수 있다.
    
    }
}



class Singleton{
	
    private static Singletone s = new Singleton();
    
    private Singleton(){ ... } // private 접근 제어자를 통해 외부에서 인스턴스 생성을 못하도록 막는다.
    
    public static Singltone getInstance(){
    	if(s==null){
        	s = new Singleton();   
        }
    	return s;
    }// public 접근 제어자를 통해 이 메소드를 통해서만 인스턴스를 생성하거나 리턴받을 수 있도록 한다.

}

이제 이외의 접근자들을 몇가지 서술한다.

static

  • 사용처 : 클래스 멤버변수, 메소드, 초기화 블럭
  • 역할
    • 변수에 사용 시 : 하나의 클래스에 단 하나 존재하는 클래스 변수를 정의할 수 있다. 이 때, 하나의 클래스에는 하나의 클래스 변수만 존재해야 함을 의미하지는 않는다. 이 클래스 변수는 모든 인스턴스에서 공용으로 사용하게 되며, 인스턴스를 생성하지 않아도 접근할 수 있다. 이는 인스턴스가 생성될 때가 아닌 클래스 로드 시점에 생성되기 때문이다.
    • 함수에 사용시 : 클래스 변수에 접근할 수 있는 클래스 메소드가 생성된다. 이 메소드는 인스턴스 변수에는 접근할 수 없다. 클래스 변수와 마찬가지로 인스턴스를 생성하지 않아도 사용할 수 있다.
    • 초기화 블럭에 사용 시 : 클래스 변수의 복잡한 초기화 과정을 담당하는 초기화 블럭을 선언할 수 있다.
class StaticTestClass{ 
    
    static int a = 100; // 스태틱 멤버 변수. 모든 인스턴스에서 공통으로 사용한다.
    int b = 1000; // 인스턴스 변수. 인스턴스가 생성될때마다 새로 할당된다.
    
    static{ ... } // 스태틱 초기화 블럭. 클래스 변수의 복잡한 초기화 과정을 수행한다.
    
    static int getA(){ return a;} // 스태틱 메소드. 인스턴스 변수에는 접근할 수 없다.

}

final

  • 사용처 : 클래스, 클래스 멤버변수, 메소드, 지역변수
  • 역할
    • 클래스 : 확장될 수 없는 클래스를 정의한다. final을 통해 정의된 클래스는 자식 클래스를 가질 수 없다.
    • 메소드 : 오버라이딩 될 수 없는 함수를 정의한다.
    • 변수 : 값이 바뀌지 않는 상수를 만든다.

class Card{
	
    // 각 인스턴스 별로 항상 동일한 값을 유지해야하는 final 변수는 상수임에도 선언과 동시에 초기화하지 않고 생성자를 통해 단 한번 초기화한다.
    final int NUMBER;
    final String KIND; // 상수이므로 대문자로 선언한다.
    
   	...중략...
    
    Card(int NUMBER, int KIND){
    	this.NUMBER = NUMBER;
        this.KIND = KIND;
    }
    // 이후 다시 final 인스턴스 변수에 접근하여 초기화하면 에러가 발생한다.
    
}

abstract

  • 사용처 : 클래스, 메소드
  • 역할
    • 클래스 : 추상 메소드를 포함하고 있는 클래스임을 명시한다.
    • 메소드 : 선언만 되어있고 구현부는 존재하지 않는 추상 메소드임을 표현한다.
abstract class AbstractTest{ // 추상 클래스 표현
	
    abstract void TestMethod(); // 추상 메소드 표현 -> 구현부가 없음
    
}

4. 다형성

하나의 참조 변수로 여러 타입의 인스턴스를 참조할 수 있는 특성을 뜻한다. 즉, 부모 클래스 타입의 참조 변수를 이용하여 자식 클래스 타입의 인스턴스들을 참조할 수 있게 된다. 하지만 자식 클래스 타입의 참조 변수로 부모 클래스 타입의 인스턴스는 참조할 수 없다.

다음의 코드를 보자.

public class polymophisom2 {
    public static void main(String arg[]){
        Product tv = new Tv_(3000, 30);
        Product car = new Car(5000, 50);
        Product phone = new Phone(2000, 20);
        Buyer b = new Buyer();
        b.buy(tv);
        b.buy(car);
        b.buy(phone);
        Tv_ tv2 = new Tv_(7000, 70);
        b.buy(tv2);

    }
}

class Product{
    int price;
    int bonuspoint;

    Product(){
        price = 1000;
        bonuspoint = 10;
    }
    Product(int price, int bonuspoint){
        this.price = price;
        this.bonuspoint = bonuspoint;
    }
}
class Buyer{
    int money = 10000;
    int bonusPoint = 0;

    void buy(Product p){
        this.money -= p.price;
        this.bonusPoint += p.bonuspoint;
        System.out.println(this.money+" "+this.bonusPoint);
    }
}

class Tv_ extends Product{
    Tv_(int price, int bonuspoint){
        super(price, bonuspoint);
    }
}
class Car extends Product{
    Car(int price, int bonuspoint){
        super(price, bonuspoint);
    }
}
class Phone extends Product{
    Phone(int price, int bonuspoint){
        super(price, bonuspoint);
    }
}


위의 코드에서 부모 클래스 타입인 Product 타입의 참조 변수는 자신의 자식 클래스들인 Tv_, Car, Phone 타입의 인스턴스들을 모두 참조하고 있다.

특이한것은 바로 매개변수인데, 이 참조 매개변수들을 이용하여 매개변수에도 다형성을 적용할 수 있다. Buyer 클래스의 buy 메소드는 Product 타입의 참조 변수를 파라미터로 받는다. 그런데 다형성에 의해 이 파라미터에 자식 클래스 타입의 인스턴스들을 모두 집어넣을 수 있게 된다.

물론 주의할 점도 있다. 이렇게 다형성을 적용하여 부모 클래스 타입의 참조 변수로 자식 클래스 타입의 인스턴스를 참조할 경우, 자식 타입에만 존재하는 메소드나 멤버 변수는 접근할 수 없다.

그리고 이러한 다형성을 이용할 때, 중복정의 된 변수가 메소드들이 존재할 수 있다. 이 때, 변수의 경우는 참조변수의 타입을 따라가며 메소드의 경우는 참조변수가 참조하는 인스턴스의 타입을 따라간다.

5. 추상 클래스

클래스가 현실 세계의 존재를 추상화한 인스턴스를 제작할 수 있는 일종의 설계도 라면, 추상 클래스는 미완성 설계도라고 부를 수 있다.

추상 클래스는 클래스처럼 멤버 변수와 메소드를 가질 수 있는 동시에 구현부가 존재하지 않는 추상 메소드를 가진 클래스를 뜻한다.

이는 다음의 예시 코드를 통해 살펴볼 수 있다.


abstract class Player{
	
    int currentPos;
    
    Player(){ // 추상 클래스도 생성자가 필요하다.
    	currentPos = 0; 
    }
    
    abstract void play(int pos); // 추상메소드. 구현부가 없다.
    abstract void stop(); // 추상 메소드.
    
    void play(){
    	play(currentPos); // 일반 메소드에서 추상 메소드를 호출할 수 있다. 중요한것은 선언부이다.
    }
}

이 때, 추상 클래스로는 곧바로 인스턴스를 만들 수가 없으며, 이 클래스를 상속받아 추상 메소드를 모두 구현한 클래스만 인스턴스를 만들 수 있다.

여러 클래스에서 공통적으로 사용될 것이 예상되는 것들을 추상 클래스로 모아두거나, 이미 공통적으로 사용되는 부분을 뽑아서 추상 클래스로 만들 수 있다. 특정 메소드가 공통적으로 사용되지만 각 클래스마다 수행해야 할 역할이 다를경우, 이 추상 메소드를 활용해 클래스 작성의 가이드라인을 만들 수 있다.

6. 인터페이스

앞서 살펴본 추상 클래스보다 더 높은 추상화 단계를 가진 요소이다. 인터페이스는 추상 클래스와 다르게 실제 구현된것이 전혀 없는, 클래스의 설계도라고 할 수 있다. 인터페이스는 상수와 추상 메소드만을 가질 수 있으며, 추상 클래스와 마찬가지로 인스턴스를 생성할 수 없다.

인터페이스는 자신에 대한 구현체로써 클래스를 가지며, 실제 의존성이 없으나 그 목적이 같은 클래스들에게 연관성을 부여해줄 수 있다. 즉, 미리 정해진 규칙에 맞게 클래스들을 제작하기 위한 가이드라인이다.

인터페이스의 모든 멤버 변수는 public static final이며 생략할 수 있다.
모든 메소드는 public abstract이며 생략할 수 있다.

예시 코드는 다음과 같다.


interface repository{
	
    void save(int userID, String userName);
    void searchByID(int userID);
    
}

class MemoryRepository implements repository{

    @Override
    void save(int userID, String userName){ ... };
    
    @Override
    void searchByID(int userID){ ... };
}

class MysqlRepositoy implements repository{
	
    @Override
    void save(int userID, String userName){ ... };
    
    @Override
    void searchByID(int userID){ ... };

}

//이 경우, DB의 구현체만 갈아끼워주면 save와 searchByID 메소드를 외부에서 활용하는데 아무런 문제가 없다.

이러한 인터페이스를 사용하면 다음과 같은 장점을 누릴 수 있다.

  • 개발 시간 단축
    • 인터페이스라는 표준이 존재하면, 그 구현체가 완벽하게 결정되어있지 않더라도 인터페이스를 통해 드러난 서비스 메소드로 구현체 이외의 서비스 로직을 병렬적으로 개발할 수 있다.
  • 선언과 구현의 분리를 통한 표준화
    • 위의 예제 코드처럼, 도입할 DB가 결정되지 않은 상황을 가정하자. 이 때, DB를 사용할 서비스 메소드들을 인터페이스를 통해 미리 선언해두고, 이 메소드 표준에 맞춰서 각 DB에 따른 구현체를 병렬적으로 구현할 수 있다. 이렇게 되면 개발 도중 DBMS를 변경하더라도 인터페이스에 맞춰서 개발했으므로 구현체만 변경해주면 시스템에 아무런 지장이 가지 않는다.
  • 관계없는 클래스들에게 관계 부여 가능
    • 종속이나 포함관계가 없지만, 각 클래스들이 동일한 목표를 위해 존재할 수 있다. 이러한 클래스들에게 인터페이스를 이용하여 관계성을 부여해줄 수 있다.

7. 디폴트 메소드

특정 인터페이스를 통해 이미 많은 클래스 구현체들이 존재하는 경우를 생각해보자. 만약 이 인터페이스에 하나의 역할을 추가해야한다면, 그에 해당하는 메소드를 추가해야한다. 그런데 인터페이스에 있는 추상 메소드들은 구현체에서 반드시 모두 구현되어야 하므로, 새로운 메소드를 추가하면 이전의 모든 구현체들을 수정해주지 않는 이상 이 프로그램은 동작하지 않는다.

따라서, 이럴 경우의 하위 호환성을 보장하기 위해 인터페이스는 디폴드 메소드를 지원한다. 그 사용 방법은 다음과 같다.

interface DefaultTest{
	
    void normalMethod(); // 일반 추상 메소드
    default defaultTestMethod(){};// 빈 구현체가 존재하는 디폴트 메소드
    
}

이러한 디폴트 메소드는 비어있는 구현체를 지원하여, 이 후 클래스에서 따로 구현해주지 않아도 오류가 생기지 않는다.

다만, 여러 디폴트 메소드가 충돌하는 경우, 각각의 구현체에서 디폴트 메소드들을 명확하게 오버라이딩 해야한다.

또한 부모 클래스에서 상속받은 메소드와 인터페이스의 디폴트 메소드가 충돌할 경우에는 디폴트 메소드가 무시되며 부모 클래스의 메소드가 상속된다.

참고자료

자바의 정석 요약집
http://www.tcpschool.com/java/java_polymorphism_concept
https://blog.lulab.net/programming-java/java-final-when-should-i-use-it/
https://goodncuteman.tistory.com/4
https://velog.io/@hkoo9329/%EC%9E%90%EB%B0%94-extends-implements-%EC%B0%A8%EC%9D%B4
https://siyoon210.tistory.com/95

profile
원리를 좋아하는 개발자

0개의 댓글