OOP의 4가지 특징
- 상속화
- 캡슐화
- 다형화
- 추상화
오늘은 위 4가지 특징 중 상속과 캡슐화에 대한 공부를 진행하였고, 해당 내용들을 두서없이 정리한 게시글입니다.
틀린 내용이 있다면 알려주세요!
상속(inheritance)
기존의 클래스를 재사용하여 새로운 클래스를 작성하는 문법 요소
상위 클래스와 하위 클래스로 나뉘며, 상위 클래스의 멤버(필드, 메서드, 이너클래스)를 하위 클래스에게 내려주는 것
이때 "두 클래스를 서로 상속 관계에 있다."라고 표현
하위 클래스는 상위 클래스가 갖는 모든 멤버를 상속받기 때문에 상위 클래스의 멤버 개수와 비교를 했을 때 언제나 같거나 많다.
상위 클래스 - 하위 클래스의 관계를 "부모 - 자식" 관계로 표현하기도 한다.
(But, 상위 - 하위 클래스라는 표현이 바람직)
"~클래스로부터 상속받았다"라는 표현보다는 "~클래스로부터 확장되었다"라는 표현이 적절
그래서 상속을 언제 쓰느냐?
각각의 클래스에서 공통된 속성과 기능을 포함한다면 해당 속성과 기능을 하나의 클래스로 정의 후 해당 클래스에서 확장해 하위 클래스를 정의하여 사용
예를 들어 프로그래머, 댄서, 가수, ... 등의 공통되는 속성(이름, 나이 등)과 기능(먹기, 자기 등)을 묶어 "사람"이라는 클래스를 정의
이후 "사람" 클래스를 상위 클래스로 하는 프로그래머 클래스, 댄서 클래스, 가수 클래스, ... 등으로 확장하여 정의
그냥 각자 정의해서 사용하면 되는 것 아니냐? 상속을 왜 쓰느냐?
공통되는 코드들을 재사용하여 코드의 중복을 제거하기 위해 사용된다.
추가로 상속을 사용함으로써 다형적 표현이 가능해진다.
다형적 표현의 예로써 "가수는 가수다"와 "가수는 사람이다"라는 명제가 둘 다 참이 된다. 따라서 "가수는 사람이고, 가수이다"라는 다형적 표현이 가능하다.
Java에서 상속을 구현하는 방법
"extends"라는 키워드를 사용하여 구현 가능
"class 클래스명 extends 상위클래스명 { ... }" 형태로 정의하여 사용
class Person {
String name;
int age;
void eat() { System.out.println("밥을 먹는다"); }
void sleep() { System.out.println("잠을 잔다"); }
}
class Singer extends Person { // Person 클래스로부터 확장하여(상속받아) Singer 클래스를 만들겠다.
String bandName;
void singing() { System.out.println("노래를 부른다"); }
void playPiano() { System.out.println("피아노를 친다"); }
}
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.name = "아메바";
p.age = 1;
p.sleep();
Singer s = new Singer();
s.name = "나가수";
s.age = 30;
s.eat();
s.singing();
}
}
Singer 클래스의 바디에 정의하지 않은 name, age 변수와 eat() 메서드를 main에서 사용함을 볼 수 있다.
name, age, eat()의 경우 Person 클래스에서 정의되어 있었기 때문에 Person 클래스로부터 확장된(상속받은) Singer 클래스이기 때문에 가능한 것
추가로, Java의 경우 단일 상속(single inheritance)만을 허용
하지만 인터페이스(interface)라는 문법 요소를 통해 다중 상속과 비슷한 효과를 낼 수 있는 방법이 존재
C++의 경우 다중 상속(multiple inheritance)을 허용
여기서 단일 상속과 다중 상속의 차이는 상위 클래스가 1개만 가능하냐, 2개 이상 지정이 가능하냐의 차이
포함(composition)
상속처럼 클래스를 재사용할 수 있는 방법
클래스의 멤버로 다른 클래스 타입의 참조 변수를 선언하는 것을 의미
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("서울", "한국");
Address address2 = new Address("도쿄", "일본");
Employee e = new Employee(1, "나개발", address1);
Employee e2 = new Employee(2, "나해커", address2);
e.showInfo();
e2.showInfo();
}
}
class Address {
String city, country;
public Address(String city, String country) {
this.city = city;
this.country = country;
}
}
1 나개발
서울 한국
2 나해커
도쿄 일본
위 예시처럼 Employee 클래스의 멤버 변수로 Address 클래스의 참조 변수를 선언하여 사용하는 방식을 "포함 관계"라고 표현
이처럼 포함 관계도 상속처럼 코드를 재사용 함으로써 중복 코드를 줄일 수 있다.
그렇다면 상속으로 구현할지, 포함 관계로 구현할지에 대한 판별 기준은 무엇일까?
이는 클래스의 관계가 "IS-A"인가? "HAS-A"인가?를 확인하는 방법이 있다.
IS-A의 경우 "~은 ~이다"로써 "Employee는 Address이다"라는 말처럼 대입을 해보면 이상하다는 것을 알 수 있다.
HAS-A의 경우 "~은 ~을 갖고 있다"로써 "Employee는 Address를 갖고 있다"라는 말처럼 대입을 해보면 자연스럽다는 것을 알 수 있다.
따라서 IS-A 관계인 경우 상속, HAS-A 관계인 경우 포함 관계로 정의하여 사용할 수 있다.
반대로 Car 클래스와 SportsCar 클래스가 있다고 해보자
"SportsCar는 Car를 갖고 있다."라는 문장은 어색하고 "SportsCar는 Car이다"라는 문장이 자연스럽다는 것을 알 수 있다.
따라서 Car와 SportsCar 클래스의 관계는 상속 관계로 정의할 수 있다.
메서드 오버라이딩(method overriding)
상위 클래스로부터 상속받은 메서드와 동일한 이름의 메서드를 재정의하는 것을 의미.
즉, 상위 클래스에서 정의한 메서드를 재정의하여 사용하겠다는 의미이다.
메서드 오버라이딩을 정의하기 위해서는 3가지의 조건이 성립되어야 한다.
- 메서드의 선언부(메서드 이름, 매개변수, 반환타입)이 상위 클래스의 메서드와 완전히 일치해야 한다.
- 접근 제어자의 범위가 상위 클래스의 메서드보다 같거나 넓어야 한다.
- 예외는 상위 클래스의 메서드보다 많이 선언할 수 없다.
정의는 알았고 그럼 메서드 오버라이딩은 왜 쓰느냐? 예제 소스로 알아보자
public class Main {
public static void main(Stirng[] args) {
Bike bike = new Bike();
Car car = new Car();
bike.run();
car.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"); }
}
Bike is running
Car is running
위 예제를 보면 Vehicle 클래스로부터 확장된(상속된) Bike 클래스와 Car 클래스에서 각각 run() 메서드를 오버라이딩하여 해당 클래스에 맞는 동작으로 재정의하여 사용함을 볼 수 있다.
이처럼 상위 클래스에서 정의한 동작이 하위 클래스에서 재정의가 필요한 경우에 사용하게 된다.
추가로 다형적 표현을 사용하여 아래 예시와 같은 동작도 가능하다. (클래스들은 위 예제와 동일하다고 생각하자)
class Main() {
public static void main(String[] args) {
Vehicle bike = new Bike(); // 상위 클래스 타입으로 선언하였으나 각각의 클래스로 객체 생성
Vehicle car = new Car();
bike.run();
car.run();
}
}
Bike is running
Car is running
이처럼 상위 클래스로 선언하고 각각의 하위 클래스 객체로 생성한 경우라면 하위 클래스에서 오버라이딩한 메서드로 동작하게 된다.
이러한 표현 방법을 응용해서 Vehicle 배열을 선언 후 멤버를 각각의 하위 클래스로 생성하여 관리할 수 있다.
super 키워드와 usper() 메서드
super 키워드는 상위 클래스의 객체를 의미하고,
super() 메서드는 상위 클래스의 생성자를 의미한다.
super 키워드와 super() 메서드는 공통적으로 상위 클래스가 존재하며 상속 관계임을 전제로 한다.
예제 코드로 super 키워드의 동작을 알아보자
public class Main {
public static void main(String[] args) {
Lower l = new Lower();
l.callCount();
}
}
class Upper {
int count = 20;
}
class Lower extends Upper {
int count = 15;
void callCount() {
System.out.println("count = " + count);
System.out.println("this.count = " + this.count);
System.out.println("super.count = " + super.count);
}
}
count = 15
this.count = 15
super.count = 20
출력 결과를 확인해보면 Lower 클래스의 callCount() 메서드 내에서 super 키워드를 사용하여 상위 클래스인 Upper 클래스의 count 변수를 호출했다는 것을 알 수 있다.
다음으로 super() 메서드를 예제 코드로 확인해보자
public class Main {
public static void main(String[] args) {
Student s = new Student();
}
}
class Person {
Person() { System.out.println("Person 클래스의 생성자 호출"); }
}
class Student extends Person {
Student() {
super();
System.out.println("Student 클래스의 생성자 호출");
}
}
Person 클래스의 생성자 호출
Student 클래스의 생성자 호출
출력 결과와 같이 Student 인스턴스가 생성될 때 super() 메서드를 통해 상위 클래스인 Person 클래스의 생성자가 호출됨을 확인할 수 있다.
위 예제 코드에서 "super()" 메서드를 빼고 돌렸는데 출력이 똑같은데요?
사실 모든 하위 클래스의 생성자의 맨 첫줄에는 super() 메서드를 호출하여야 한다.
개발자가 하위 클래스의 생성자에 명시적으로 super() 메서드를 호출하지 않은 경우에는 컴파일러가 자동으로 "super();" 명령을 추가해준다.
그렇기에 위 예제 코드에서 Student 클래스의 생성자 첫줄에 명시된 "super();" 명령을 제거하거나 주석처리한 후 돌려봐도 출력은 동일하다는 것을 알 수 있다.
하지만 여기서 중요한 점은 하위 클래스의 생성자에 super() 메서드를 명시적으로 작성하지 않았을 때,
"super();" 형태의 상위 클래스의 기본 생성자를 호출하는 명령이 추가된다는 사실이다.
상위 클래스에서 매개변수를 갖는 생성자를 정의한 경우, 컴파일러는 상위 클래스에 개발자가 정의한 생성자가 존재하기 때문에 기본 생성자를 만들어 주지 않는다.
따라서 상위 클래스에는 매개변수가 없는 기본 생성자가 존재하지 않게되는데, 하위 클래스에서는 상위 클래스의 기본 생성자를 호출하려하기 때문에 에러가 발생하게 된다.
그러므로 상위 클래스에 매개변수를 갖는 생성자를 정의하는 경우에는 명시적으로 매개변수가 없는 기본 생성자를 정의하는 습관을 갖는게 좋다.
Object 클래스
Java의 클래스 상속계층도에서 최상위에 위치한 클래스
따라서 Java의 모든 클래스는 Object 클래스를 상속받는다는 명제는 참
실제로 컴파일러에서 컴파일 중 다른 클래스로부터 아무런 상속을 받지 않는 클래스가 있다면 "extends Object"를 추가하여 Object 클래스를 상속받도록 정의한다.
따라서 모든 클래스는 Object 클래스의 멤버를 상속받아 사용할 수 있게 된다는 말이다.
그렇다면 모든 클래스에서 사용할 수 있는 Object 클래스의 대표적인 메서드 몇가지를 알아보자
메서드 명 | 반환 타입 | 내용 |
toString() | String | 객체의 정보를 문자열로 출력 |
equals(Object obj) | boolean | 등가 비교 연산(==)과 동일하게 스택 메모리값을 비교 |
hashCode() | int | 객체의 위치정보 관련 메서드. Hashtable 또는 HashMap에서 동일 객체 여부를 판단할 때 사용 |
wait() | void | 현재 쓰레드 일시정지 |
notify() | void | 일시정지 중인 쓰레드 재시작 |
캡슐화(encapsulation)
특정 목적을 위한 속성과 기능을 묶어서 추상화하는 것을 의미
하나로 묶음으로 인하여 관리가 용이해지며 내부 속성이나 메서드를 외부로부터 보호할 수 있어진다.
이렇게 내부 속성이나 메서드를 외부로부터 보호하는 것을 정보 은닉(information hiding)이라 한다.
정보 은닉을 하는 이유는
- 데이터 보호 목적
- 내부적으로만 사용되는 데이터에 대한 불필요한 외부 노출 방지
이러한 맥락에서 캡슐화의 가장 큰 장점은 관리의 용이함과 정보 은닉이라고 정리할 수 있다.
외부로부터 객체의 속성과 기능이 함부로 변경되지 못하도록 막고, 데이터가 변경되더라도 다른 객체에 영향을 주지 않기에 독립성을 확보할 수 있다.
Java에서 캡슐화를 구현하는 방법
접근제어가(access modifier)를 사용하여 객체의 속성과 기능의 접근 가능 범위를 지정하여 구현
패키지(package)
특정한 목적을 공유하는 클래스와 인터페이스의 묶음을 의미
클래스들과 인터페이스들을 그룹화하여 효과적으로 관리하기 위한 목적
폴더를 만들어서 폴더별로 파일들을 관리하는 것으로 비유할 수 있다.
그렇기에 동일한 클래스명을 같은 클래스가 존재한다고 하더라도 해당 클래스들이 각각 다른 패키지에 속하는 경우 패키지명으로 구분이 가능하여 문제없이 사용이 가능하다.
소스 코드의 첫 번째 줄에 "package 패키지명"으로 패키지를 정의, 패키지 선언이 없으면 이름 없는 패키지에 속한다.
package sample.test;
public class SampleTest { ... }
Java에 기본적으로 포함되어 있는 대표적인 패키지들
자바의 기본 클래스들을 모아 놓은 java.lang
확장 클래스들을 묶어 놓은 java.util
입출력과 관련된 클래스들을 묶어 놓은 java.io, java.nio
등이 존재
String 클래스의 실제 이름은 "java.lang.String"이며 여기서 java.lang이 패키지명이고 .(dot)을 사용하여 디렉터리 계층구조를 나타낸다.
다른 패키지에 존재하는 클래스를 사용하려면 어떻게 해야하지?
패키지명을 모두 포함하여 호출하면 사용이 가능하긴 하다.
package sample.test;
public class SampleTest { ... }
// ---- 위와 아래는 각자 다른 파일 및 다른 패키지
package sample.test2;
public class Test {
public static void main(String[] args) {
sample.test.SampleTest st = new sample.test.SampleTest();
}
}
하지만 예제 코드를 보다시피 사용하기가 매우 불편하기 때문에 보통 import문을 사용하여 쓴다.
"import 패키지명.클래스명;" 또는 "import 패키지명.*;"이라는 명령을 통해 해당 패키지를 현재 패키지에 추가하여 클래스명으로만 호출하여 사용하는 게 가능해진다.
package sample.test2;
import sample.test.*; // import문을 사용하여 sample.test 패키지의 모든(*) 클래스 추가
public class Test {
public static void main(String[] args) {
SampleTest st = new SampleTest(); // import문으로 sample.test의 클래스들을 추가하였기 때문에 그냥 클래스명으로 호출하여 사용
}
}
추가로 import문은 컴파일 과정에서 처리가 되기 때문에 프로그램 성능에는 영향을 주지 않는다.
제어자(modifier)
Java에서 제어자는 클래스, 필드, 메서드, 생성자 등에 부가적인 의미를 부여하는 키워드를 의미
Java에서 제어자는 크게 "접근 제어자"와 "기타 제어자"로 구분할 수 있다.
접근 제어자 | public, protected, (default), private |
기타 제어자 | static, final, abstract, native, transient, synchronized, ... 등 |
접근 제어자(access modifier)
접근 제어자를 사용하여 클래스 외부로의 불필요한 데이터 노출을 방지(data hiding)할 수 있고, 외부로부터 데이터가 임의로 변경되지 않도록 막을 수 있다.
데이터 보호의 측면에서 매우 중요한 제어자이다.
접근 제어자 | 클래스 내 | 패키지 내 | 다른 패키지의 하위 클래스 |
패키지 외 |
Private | O | X | X | X |
Default | O | O | X | X |
Protected | O | O | O | X |
Public | O | O | O | O |
Default의 경우 아무런 접근 제어자를 지정하지 않았을 때 자동으로 할당되는 제어자이다.
각각의 접근 제어자에 따른 동작을 예제 코드로 확인해 보자
package package1;
import package2.*;
class Main {
public static void main(String[] args) {
Parent p = new Parent();
// System.out.println("p.a = " + p.a); // a 변수는 private로 지정되어 있기에 다른 클래스에서 호출 불가로 에러 발생
// System.out.println("p.b = " + p.b); // b 변수는 default로 지정되어 있기에 다른 패키지에서 호출 불가로 에러 발생
// System.out.println("p.c = " + p.c); // c 변수는 protected로 지정되어 있기에 다른 패키지에 존재하며 하위 클래스가 아니라 호출 불가로 에러 발생
System.out.println("p.d = " + p.d); // d 변수는 public으로 지정되어 있기에 어디서든 호출 가능
}
}
class Child extends Parent { // package2에 정의된 Parent 클래스를 확장하는 Child 클래스 정의
void printChildNum() {
// System.out.println("a = " + a); // a 변수는 private로 지정되어 있기에 다른 클래스에서 호출 불가로 에러 발생
// System.out.println("b = " + b); // b 변수는 default로 지정되어 있기에 다른 패키지에서 호출 불가로 에러 발생
System.out.println("c = " + c); // c 변수는 protected로 다른 패키지에 존재하지만 하위 클래스로 지정되어 있기에 호출 가능
System.out.println("d = " + d); // d 변수는 public으로 지정되어 있기에 어디서든 호출 가능
}
}
// ---- 위와 아래는 다른 파일
package package2;
public class Parent { // Parent 클래스의 접근 제어자는 public으로써 어디서든 접근 가능
private int a = 10;
int b = 20;
protected int c = 30;
public int d = 40;
public void printNum() { // Parent의 printNum() 메소드는 public으로써 어디서든 접근 가능
System.out.println("a = " + a); // Parent 클래스 내부에서 a를 호출하였기 때문에 사용 가능
System.out.println("b = " + b);
System.out.println("c = " + c);
System.out.println("d = " + d);
}
}
class Test { // package2 패키지에 Test 클래스를 정의
Parent p = new Parent();
void printTestNum() {
// System.out.println("a = " + a); // a 변수는 private로 지정되어 있기에 다른 클래스에서 호출 불가로 에러 발생
System.out.println("b = " + b); // b 변수는 default로 지정되어 있기에 동일 패키지 내에서는 호출 가능
System.out.println("c = " + c); // c 변수는 protected로 지정되어 있기에 동일 패키지 내에서는 호출 가능
System.out.println("d = " + d); // d 변수는 public으로 지정되어 있기에 어디서든 호출 가능
}
}
getter와 setter 메서드
캡슐화를 하기 위해서 객체의 변수들에게 접근 제어자를 사용하게 되는데, 이때 private로 선언된 변수들의 값을 수정하거나 확인하고 싶을 때의 방법은 무엇이 있을까?
기본적으로 private으로 선언된 변수는 해당 클래스 외에는 접근이 불가능하기에 getter와 setter 메서드를 만들어서 사용하게 된다.
여기서 말하는 getter와 setter 메서드는 단순하게 private 변수들의 값을 설정(set)하거나 확인(get)하는 동작을 구현한 메서드다.
예제 코드를 참고하여 구현 방법 및 사용 방법을 확인해 보자
public class Main {
public static void main(String[] args) {
Worker w = new Worker();
w.setName("나일꾼");
w.setAge(30);
w.setId(3);
System.out.println("이름 : " + w.getName());
System.out.println("나이 : " + w.getAge());
System.out.println("ID : " + w.getId());
}
}
class Worker {
private String name;
private int age;
private int id;
public String getName() { // name 변수의 getter 메서드
return name;
}
public void setName(String name) { // name 변수의 setter 메서드
this.name = name;
}
public int getAge() { // age 변수의 getter 메서드
return age;
}
public void setAge(int age) { // age 변수의 setter 메서드
if(age < 1) return;
this.age = age;
}
public int getId() { // id 변수의 getter 메서드
if(id < 1) return;
return id;
}
public void setId(int id) { // id 변수의 setter 메서드
this.id = id;
}
}
이름 : 나일꾼
나이 : 30
ID : 3
예제 코드를 보면 알겠지만 getter와 setter 메서드는 별게 없다.
의미 그대로 확인과 설정을 하는 메서드를 구현하면 된다.
여기서 getter와 setter 메서드명은 관습으로써 getX(), setX()로 정의하면 된다.
아니 굳이 왜 getter와 setter 메서드를 만들어서 설정을 하나요? 그냥 변수에 private를 할당하지 않고 대입하면 되는 것 아닌가요?
정보 은닉을 구현하면서도 데이터를 조작할 수 있게 하기 위함이다.
그렇다면 왜 굳이 정보 은닉을 해야 하느냐? 라는 의문이 생길 수 있다.
정보 은닉을 함으로써 타입이나 메서드, 구현들에 의존하는 것을 막아 객체 간의 결합도를 약화시키고, 이에 따라 해당 기능의 수정 또는 다른 기능으로의 교체가 쉬워진다.
또한 getter와 setter 메서드를 구현함으로써 해당 클래스의 사용이 편해지는 이유도 있다.
setter 메서드에 유효성 검사문을 구현해두면 개발자는 해당 설정들에 대한 유효성 검사를 따로 하지 않고 setter 메서드를 호출함으로써 자연스럽게 유효성 검사가 진행된다.
또한 해당 클래스의 내부 구조를 모르는 상태에서 setter 메서드만 사용하면 알아서 설정이 되기 때문에 편하게 설정을 할 수 있게 된다.
getter 메서드의 경우 내부에서 데이터 가공 후 개발자에서 반환을 해 줄 수 있으므로 이 또한 내부 구조를 모르는 상태에서 그냥 getter 메서드만 호출하여 데이터를 받아와 사용하면 끝나는 편리함이 생긴다.
참조
정보 은닉 - http://wiki.hash.kr/index.php/%EC%A0%95%EB%B3%B4%EC%9D%80%EB%8B%89
'Develop > TIL(Today I Learned)' 카테고리의 다른 글
[2022.05.24] 자료구조/알고리즘 - 재귀 (0) | 2022.07.25 |
---|---|
[2022.05.23] 모의 기술 면접 (0) | 2022.07.25 |
[2022.05.13] OOP 심화 2/2 - 다형화, 추상화 (0) | 2022.07.25 |
[2022.04.28] Web 기초 - CSS (0) | 2022.07.25 |
[2022.04.27] Web 기초 - HTML (0) | 2022.07.25 |