<Java> 객체지향프로그래밍 4
저번 포스팅은 객체지향의 근본 개념인 상속에 대해 배워보았습니다.이번 포스팅은 또 다른 근본 개념인 캡슐화(제어자)와 다형성(polymorphism)에 대한 포스팅을 진행해보겠습니다.
제어자(modifier)
- 제어자란 클래스,변수 또는 메서드의 선언부에 함께 사용되어 부가적인 의미를 부여합니다.제어자는 크게 접근 제어자와 그 외의 것들로 구분합니다.
- 접근 제어자 : public, protected, default, private
- 그 외 : static,final,astract etc..
- 제어자는 클래스나 멤버변수와 메서드에 주로 사용되며, 하나의 대상에 여러 제어자를 조합하여 사용합니다.단, 접근 제어자는 4가지 종류 중 하나만을 사용할 수 있습니다.
- 접근 제어자를 알아보기 이전에 주로 사용하는 제어자 static, final, abstract에 대해서 먼저 보겠습니다.
static
- 앞서 객체지향 개념을 배우며 종종 사용하셨던 제어자입니다.static의 의미는 ’클래스의’,’공통적인’입니다.즉,인스턴스 변수와 달리 static이 붙은 클래스 변수들은 공통적인 값을 유지하는 것이라고 보시면 됩니다.하나의 변수를 모든 인스턴스가 공유한다는 의미와도 일맥상통합니다.
- 또한 static 키워드가 붙은 멤버변수와 메서드들은 인스턴스가 아닌 클래스의 메모리 영역에 초기화 되었기에 인스턴스를 생성하지 않아도 사용 가능하다는 것도 알고 계실것입니다.
- static 키워드가 붙은 변수와 메서드의 경우 Method Area에 클래스가 로드될때 한번만 실행되며 해당 클래스를 사용하는 프로그램이 종료되기전까지 계속 사용가능합니다.(프로그램 시작할때 메모리를 점유해서 종료될때 메모리를 반납합니다)
final
- final 제어자는 ‘마지막의’,’변경될 수 없는’ 뜻을 가지고 있으며,거의 모든 대상에 적용가능합니다.
- 변수에 적용할 시 값을 변경할수 없는 상수가 되며,메서드에 적용할 경우 오버라이딩을 할 수 없는 메서드가 됩니다.
- final이 붙은 멤버 변수의 경우 생성자를 이용해서도 초기화 가능합니다.일반적으로 상수는 선언과 동시에 초기화를 진행해주지만 인스턴스변수(static 없음)의 경우 생성자에서 초기화 되도록 할 수 있습니다.클래스 내에 해당 인스턴스 변수에 대한 매개변수를 갖는 생성자를 선언하여,인스턴스를 생성할 때 final이 붙은 멤버변수를 초기화하는데 필요한 값을 매개변수로부터 받을 수 있습니다.
- 이러한 방식을 통해 생성되는 인스턴스의 멤버변수마다 서로다른 값을 갖도록 할 수 있습니다.또한 이후 배울 다형성을 보장하는 방식이기도 합니다.
- 예제를 통해 코드 구조를 알아봅시다.
class FinalCard{
final int NUMBER;
final String KIND;
static int width = 100;
static int height = 250;
public FinalCard(int NUMBER, String KIND) {
this.NUMBER = NUMBER;
this.KIND = KIND;
}
public FinalCard() {
this(1, "HEART");
}
@Override
public String toString() {
return KIND + " " + NUMBER;
}
}
public class FinalCardTest {
public static void main(String[] args) {
FinalCard finalCard = new FinalCard(10,"SPADE");
System.out.println("finalCard = " + finalCard.toString());
FinalCard defaultCard = new FinalCard();
System.out.println("defaultCard.toString() = " + defaultCard.toString());
}
}
int NUMBER와 String KIND
를 매개변수로 받는 생성자가 보이시나요?이러한 생성자를 통해서 FinalCard의 인스턴스들이 각기 다른 값을 가질 수 있게 되는것입니다.실제로 위와 같은 구조는 자바의 웹 프레임워크인 스프링의 중요개념을 배울때도 적용되니 눈여겨 봐 두시면 좋습니다.
abstract
- “미완성의”,”추상적인”이라는 뜻이 가진 abstract 제어자는 메서드의 선언부만 작성하고 실제 수행내용은 구현하지 않은 추상 메서드를 선언하는데 사용됩니다.또한 클래스에도 사용되며 해당 클래스가 추상 클래스라는 것을 선언해줍니다.
- 추상 클래스 및 메서드라 함은 아직 완성되지 않은 메서드가 존재하는 클래스라고 이해해두셔도 좋습니다.
- 추상 메서드 및 클래스에 대한 내용은 곧이어 다룰 예정입니다.
접근 제어자
- 접근 제어자는 멤버 또는 클래스에 사용되어,해당되는 멤버 또는 클래스를 외부에서 접근하지 못하도록 제한하는 역할을 합니다.접근 제어자를 설정해주지 않을 경우 접근 제어자 default가 자동으로 설정된것이라고 생각하시면 됩니다.4가지 접근 제어자의 접근 범위는 아래와 같습니다.
- private : 같은 클래스 내에서만 접근이 가능하다.
- default : 같은 패키지 내에서만 접근이 가능하다.
- protected : 같은 패키지 내에서,그리고 다른 패키지의 자식 클래스에서 접근 가능
- public : 접근 제한이 전혀 없다.
- 접근 범위가 넓은 쪽에서 좁은 쪽의 순으로 나열하면 아래와 같다.
- 아마 public과 private는 이해가 직관적으로 가실텐데 protected는 조금 헷갈릴 수도 있습니다. protected는 패키지에 관계없이 상속관계에 있는 자식(자손)클래스에서 접근 할 수 있도록합니다.즉,동일한 패키지면 접근가능과 함께 본인의 자식으로 상속관계에 있는 클래스가 접근 할 수 있습니다.그래서 defalut보다 protected가 범위가 더 넓습니다.
캡슐화(Encapsulation)
- 그러면 자바에서는 왜 접근 제어자를 통해서 동일한 패키지 또는 클래스 내에서만 멤버에 접근 할 수 있도록 하였을까요?여기서 나오는 객체지향의 중요한 개념이 캡슐화입니다.여러분들이 아플때먹는 캡슐형 알약을 상상해봅시다.캡슐 그 상태 그대로 알약 내부에 어떤게 들어있는지 알수 있을까요?직접 겉을 감싸고 있는 캡슐을 벗겨내야 알 수 있을것입니다.
- 이러한 캡슐의 목적과 접근 제어자의 목적은 동일합니다.하나의 클래스 내에 정의된 멤버 변수를 다른 클래스 또는 패키지에서 보지 못하게 하는것입니다.좀 더 프로그래밍적을 말하면 해당하는 멤버에 대한 읽고 쓰는 기능을 제어하는것입니다.
- 즉,데이터가 유효한 값을 유지하도록, 또는 비밀번호와 같은 데이터를 외부에서 함부로 변경하지 못하도록 하기 위해서 접근을 제한하는 것입니다.
- 그러면 이제 캡슐화를 이용한 예제를 직접 작성해 봅시다.
class Time{
private int hour,minute,second;
public Time(int hour, int minute, int second) {
setHour(hour);
setMinute(minute);
setSecond(second);
}
public int getHour() {
return hour;
}
public int getMinute() {
return minute;
}
public int getSecond() {
return second;
}
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;
}
@Override
public String toString() {
return "Time{" +
"hour=" + hour +
", minute=" + minute +
", second=" + second +
'}';
}
}
public class TimeTest {
public static void main(String[] args) {
Time t = new Time(12, 25, 11);
System.out.println(t);
t.setHour(t.getHour() + 1);
System.out.println(t);
}
}
- Time이라는 클래스의 멤버변수에 직접 접근하지 못하고,getter를 통해 접근하고 setter통해 설정해야합니다.이러한 getter,setter 등의 용어는 약속되어 사용되어지는 용어입니다.자세한 내용이 궁금하시면 자바 빈 규약에 대해 구글링 하시면 됩니다.
생성자의 접근 제어자
- 생성자에 접근 제어자를 사용할 경우, 인스턴스의 생성을 제어할 수 있습니다.보통 생성자의 접근 제어자는 해당 클래스와 동일하지만 특별한 경우 다르게 설정 가능합니다.
- 만약 생성자의 접근 제어자를 private로 지정하면, 외부에서 생성자에 접근할 수 없으므로 인스턴스를 생성할 수 없습니다.이러한 경우는 클래스 내부의 메서드를 활용해서 인스턴스를 생성해주고 생성된 단 하나의 인스턴스를 외부에서 사용할 수 있도록 해줍니다.
- 이러한 구조를 Sington 패턴이라고 합니다.스프링의 스프링 빈 구조 등 단 하나만의 인스턴스가 필요할 경우 종종 쓰이는 패턴이니 기억해두시면 훗날 도움이 될것입니다.아래의 코드를 통해 싱글톤 패턴을 알아봅시다.
class Singleton{
private static final Singleton singleton = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return singleton;
}
}
public class SingletonTest {
public static void main(String[] args) {
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();
System.out.println("singleton1 = " + singleton1);
System.out.println("singleton2 = " + singleton2);
}
}
- 해당 코드의 출력 결과를 보면 동일한 인스턴스에 대한 주솟값(사실은 주소의 해시코드값을 16진수화한것)이 출력 된것을 볼 수 있습니다.자주 사용하는 패턴은 아니지만 자바 공부를 꾸준히 하다보면 언젠가 다시 볼 패턴입니다.
다형성(polymorphism)
- 상속과 더불어 객체 지향의 양대 산맥이라고 불리는 개념입니다.만약 해당 포스팅을 읽기전에 상속에 대한 개념이 헷갈리시는 분들은 다시한번 상속의 정확한 개념을 짚고 넘어오시길 바랍니다.
- 객체지향에서 다형성이란 “여러 가지 형태를 가질 수 있는 능력”으로 정의됩니다.특히 자바에서는 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있도록 함으로써 다형성을 구현했습니다.조금 더 상세히 설명하면 부모 클래스의 타입의 참조변수로 자식클래스의 인스턴스를 참조할 수 있다는 것입니다.
- 사실 이해가 잘 가지 않으실 것입니다.간단한 코드를 통해 설명하겠습니다.
class Book{
int page;
String name;
}
class EBook extends Book{
String expiryDay;
void savePage(){}
void loadPage(){}
}
- Book이라는 객체가 부모, EBook이 자식으로 서로 상속 관계에 있는 상황입니다.
- 지금껏 우리는
Book book = new Book()
또는EBook eBook = new EBook()
와 같이 인스턴스의 타입과 일치하는 타입의 참조변수만을 사용했습니다.보통의 상황은 위와 같이 서로 일치시켜 사용하지만 다형성을 이용하면 조금 달라집니다.즉,참조변수의 타입과 인스턴스의 타입이 일치하지 않는다는 의미입니다. - 그러면 두가지의 경우의 수가 있습니다.
Book book = new EBook()
또는EBook eBook = new Book()
입니다.전자의 경우 부모 타입의 참조변수로 자식 인스턴스를 참조하였고,후자는 자식 타입의 참조변수로 부모 인스턴스 참조하였습니다. - 하지만 코드를 쳐보면 후자의 경우 바로 컴파일 에러가 발생합니다.왜냐하면 실제 인스턴스인 Book()의 멤버 갯수보다 참조변수인 eBook이 사용할 수 있는 멤버 개수가 더 많기 때문입니다.즉,참조변수가 사용하려는 멤버가 인스턴스에 존재하지 않는 상황이 발생합니다.
- 결론적으로 자식의 참조변수로 부모 인스턴스를 참조할 수 없다는 것을 이해하시면 됩니다.
- 그러면
Book book = new EBook()
과EBook eBook = new EBook()
을 비교해봅시다.후자의 경우,자기 자신의 타입의 참조변수로 자신의 인스턴스를 참조하면 당연히 모든 멤버를 전부 사용할 수 있습니다.반면에 전자의 경우 book이라는 참조변수는 EBook()클래스가 가지는 EBook() 클래스만의 멤버는 사용하지 못합니다.즉,savePage나 loadPage와 같은 메서드를 사용하지 못 한다는 것입니다. - 그러면 구태여 모든 기능을 사용하지 못하는데 왜 부모 타입의 참조변수로 자식 인스턴스를 참조하여 다형성을 구현해야 할까요?이에 대한 해답은 추후 인터페이스를 배울때 알게될 것입니다.지금은 위와 같은 과정으로 다형성을 구현하는구나 정도만 이해하셔도 충분합니다.
참조 변수의 형변환
- 기본형 변수와 동일하게 참조변수도 형변환이 가능합니다.단,서로 상속관계에 있는 클래스 사이에서만 가능합니다.즉,부모타입에서 자식타입으로 또는 자식타입에서 부모타입으로만 형변환 할 수 있습니다.
- 참고로 바로 위의 부모뿐만 아니라 부모의 부모로도 형변환이 가능합니다.결론적으로 모든 클래스는 모든 클래스의 정점인 Object클래스로 형변환이 가능합니다.
- 기본형 변수의 형변환에서 작은 자료형에서 큰 자료형으로의 형변환은 생략하듯이 참조변수의 형변환도 마찬가지입니다.즉,자식 타입의 참조변수를 부모 타입으로 형변환 할때는 생략 할 수 있습니다.
- 앞서 말한 다형성을 보장하기 위해 부모타입의 참조변수를 자식 타입의 인스턴스에 참조하는 것과는 관련은 있지만 완전히 다른 개념이니 헷갈리시지 않길 바랍니다.
- 자식 타입의 참조변수를 부모 타입의 참조변수로 캐스팅 하는것을 업 캐스팅이라고 하고 부모타입의 참조변수를 자식타입으로 하는 것은 다운 캐스팅이라고 합니다.
- 그러면 부모 타입의 참조변수를 자식 타입으로 형변환 할 경우에는 왜 형변환 연산자를 생략하지 못할까요?이는 앞서 다형성에서 언급한 다룰 수 있는 멤버의 개수와 연관있습니다.부모 타입에서 자식타입으로 캐스팅을 하게 될 경우,해당 참조변수가 다룰 수 있는 멤버의 갯수가 같거나 늘어나기 때문에 자손타입으로의 형변환은 생략할 수 없습니다.
- 앞서 다룬
Book book = new EBook()
의 경우에도 실제로는Book book = (Book) new EBook()
입니다.자식을 부모에 캐스팅하기에 형변환을 생략 했을 뿐이지 실제로는 업캐스팅이 동작했습니다. - 예제를 통해 형변환을 직접 진행해봅시다.
public class CastingTest1 {
public static void main(String[] args) {
Car car = null;
FireEngine fireEngine = new FireEngine();
FireEngine fireEngine1 = null;
car = fireEngine;//자식 -> 부모 Casting, 생략 가능
fireEngine1 = (FireEngine) new Car();//부모 -> 자식 Casting
fireEngine1.water();
}
}
class Car{
String color;
int door;
void drive() {
System.out.println("Car.drive");
}
void stop() {
System.out.println("Car.stop");
}
}
class FireEngine extends Car {
void water() {
System.out.println("FireEngine.water");
}
}
- 참조변수 car의 경우 처음에는 아무런 인스턴스를 참조하지 않다가 자식 타입의 인스턴스를 참조합니다.이때 대입하는 fireEngine 참조변수는 자동으로 형변환이 되고 생략가능합니다.반면에 fireEngine1에 Car 인스턴스를 대입할때는 형변환을 명시해줘야 가능합니다.
- 동일한 클래스를 사용해 또 다른 예제를 봅시다.
public class CastingTest2 {
public static void main(String[] args) {
Car car = new Car();
Car car1 = null;
Car car2 = new FireEngine();
FireEngine fireEngine = null;
car.drive();
fireEngine = (FireEngine) car2;
fireEngine.water();
car2 = fireEngine; // 자식 타입 => 부모 타입 형변환 생략가능
car2.drive();
}
}
- Line11번째에 왜 car2라는 참조변수를 넣어야 컴파일 에러가 발생하지 않을까요?car의 경우 참조 할 수 있는 인스턴스 타입이 Car타입이기에 FireEngine타입의 참조변수에 대입할 수 없습니다.car1은 당연히 Null Pointer Exception이 발생할 것입니다.그래서 FireEngine타입의 인스턴스를 참조하는 car2를 대입할 수 있습니다.
- 사실 처음 참조변수의 형변환을 공부하시면 상당히 많이 헷갈립니다.그럴때 마다 참조변수가 현재 참조할 수 있는 인스턴스의 타입이 뭔지 파악하면 수월합니다.
instance of 연산자
instanceof
연산자의 경우 참조변수가 현재 참조하고 있는 인스턴스의 실제 타입을 알아보기 위해 사용합니다.주로 조건문에서 사용되며,instanceof의 왼쪽에는 참조변수를 오른쪽에는 타입(클래스명)이 위치합니다.그리고 연산의 결과로는 boolean타입이 반환됩니다.- 예제를 통해 바로 알아보겠습니다.기존에 사용한 FireEngine 클래스를 사용합니다.
public class InstanceofTest {
public static void main(String[] args) {
FireEngine fe = new FireEngine();
if (fe instanceof FireEngine) {
System.out.println("fe is instanceof FireEngine");
}
if (fe instanceof Car) {
System.out.println("fe is instanceof Car");
}
if (fe instanceof Object) {
System.out.println("fe is instanceof Object");
}
System.out.println("fe.getClass().getName() = " + fe.getClass().getName());
}
}
- 생성된 인스턴스는 FireEngine타입임에도 불구하고 Object, Car에 대한 연산의 결과가 true가 나옵니다.즉, FireEngine클래스는 Object와 Car로부터 상속을 받았기 때문에 부모 클래스를 포함하고 있기 때문입니다.
- 결론적으로 실제 인스턴스와 같은 타입의 연산 뿐만 아니라 조상타입의 “instanceof” 연산에도 true를 결과로 얻고 해당 조상 타입으로 형변화을 해도 아무런 이상이 없다는 뜻입니다.
참조변수와 인스턴스의 연결
- 해당 챕터는 멤버변수가 부모 클래스와 자식 클래스에 중복으로 정의된 경우를 다룹니다.메서드의 경우 부모 클래스의 메서드를 자식의 클래스에서 오버라이딩한 경우에도 참조변수의 타입과 관계없이 항상 해당 인스턴스 타입의 메서드가 호출됩니다.하지만 멤버변수의 경우 참조변수의 타입에 따라 그 결과가 달라집니다.
- 멤버변수가 조상 클래스와 자손 클래스에 중복으로 정의된 경우, 조상타입의 참조변수를 사용했을 때는 조상 클래스에 선언된 멤버변수가 사용되고,자손타입의 참조변수를 사용했을 때는 자손 클래스에 선언된 멤버변수가 사용됩니다.
- 예제를 통해 보면 쉽게 이해할 수 있습니다.
public class BindingTest {
public static void main(String[] args) {
Parent1 p = new Child1();
Child1 c = new Child1();
System.out.println("p.x = " + p.x);
p.method();
System.out.println("c.x = " + c.x);
c.method();
}
}
class Parent1{
int x = 100;
void method() {
System.out.println("Parent.method");
}
}
class Child1 extends Parent1{
int x = 200;
void method() {
System.out.println("Child1.method");
}
}
- 메인함수의 참조변수 p,c 모두 Child1() 인스턴스를 참조하고 있습니다.그리고 Parent1과 Child1 모두 동일한 멤버변수를 정의하고 있는 상황입니다.
- 결과를 보시면 아시겠지만 p.x는 100이 나오고 c.x는 200이 출력됩니다.반면에 메서드의 경우 모두 참조하고 있는 인스턴스 타입의 메서드를 호출합니다.
Ref:자바의 정석 - 남궁성