인터페이스란 쉽게 말하자면 프로그램 설계에서 지켜야 할 최소한의 규칙 입니다. 클래스들이 어떤 기능을 반드시 구현해야 하는지 명확하게 정해주는 설계도 역할을 합니다. 실제 구현은 신경 쓰지 않고, 어떤 기능이 무조건 있어야 하는지만 정해주는 역할을 합니다.
개발하는 사람마다, 작성하는 메서드 이름을 다르게 작성할 수도 있고, 메서드를 작성하는 방식이 달라질 수도 있습니다. 이렇게 된다면 코드가 많이 복잡해질 것입니다. 이를 예방하기 위해 최소한의 규칙을 정하여 일관성을 지킵니다.
클래스에서 implements라는 키워드로 인터페이스를 활용할 수 있습니다. 여기서 인터페이스를 구현한 클래스를 구현체라고 부르기도 합니다.
클래스는 여러 가지의 인터페이스를 한 번에 구현할 수 있습니다.
interface Animal {
void eat();
}
interface Flyable {
void fly();
}
class Bird implements Animal, Flyable {
/* 새는 동물이면서 날 수 있음을 표현 */
public void eat() {
/* 실제 구현 코드 작성 */
}
public void fly() {
/* 실제 구현 코드 작성 */
}
}
인터페이스는 인터페이스끼리 상속(extends) 또한 가능합니다.
interface FlyableAnimal extends Animal, Flyable {
void land();
/* FlyableAnimal을 구현하려면,
Animal, Flyable에 있는 메서드 또한 구현해야 함 */
}
인터페이스에서는 public static final 변수, 즉, 상수만 선언할 수 있습니다.
왜 인터페이스에서 선언하는 변수는 public static final이어야만 할까?
인터페이스는 앞서 설명했듯이, 객체의 설계만을 정의하는 곳입니다. 이런 기능을 구현하라는 정도만 알려주는 역할이지, 실제로 값을 저장해주거나 상태를 관리하지는 않습니다.
따라서 클래스에서 사용하는 인스턴스 변수는 가질 수 없습니다. 변수가 객체의 소유가 아니므로 static이어야 합니다.
그리고, 규칙을 이야기하는 것이 인터페이스의 역할이기 때문에, 누구나 접근이 가능해야하므로 public이어야 합니다.
마지막으로, 여러 클래스가 이 값을 공유해야 하다 보니, 혼란을 없애기 위해서 값은 변경 불가능한 final이어야 합니다.public static final을 생략하더라도 오류는 나지 않지만 컴파일러가 자동으로 public static final을 붙여줍니다.
물론, 변수의 선언이 가능하지만, 규칙을 정의하는 역할이기 때문에 변수를 선언하는 것은 최소화하는 것이 가장 좋을 것입니다.
캡슐화는 객체의 정보를 외부에서 직접적으로 접근하지 못하게 보호하는 것을 의미합니다. 캡슐화를 한다면, 객체의 내부 데이터에 접근을 쉽게 하지 못하니까 데이터에 문제가 생기는 것을 막을 수 있습니다. 즉, 중요한 데이터를 숨기면서 꼭 필요할 때에만 접근할 수 있도록 하는 것이 캡슐화를 하는 이유입니다.
여기서, 접근 제어자(Access Modifier)를 이용하여 캡슐화를 구현할 수 있습니다.
| 접근제어자 | 클래스 내부 | 패키지 내부 | 상속한 클래스 | 전체 |
|---|---|---|---|---|
| public | O | O | O | O |
| protected | O | O | O | X |
| default | O | O | X | X |
| private | O | X | X | X |
보통 아무것도 작성하지 않았을 때에는 default가 적용된다고 생각하면 됩니다.
public class Person {
public String name; // 외부에서 접근 가능
private String phoneNumber; // 외부에서 접근 불가능
public void methodA() { ... } // 외부에서 접근 가능
private void methodB() { ... } // 외부에서 접근 불가능
}
public class Main {
public static void main(String[] args) {
Person person = new Person();
person.name; // 가능
person.phoneNumber; // 불가능
person.methodA(); // 가능
person.methodB(); // 불가능
}
}
이전 게시글에서 다루었던 내용입니다.
private으로 지정한 요소들은 외부에서 직접적으로 접근할 수 없기 때문에, 이를 간접적으로 읽거나(Getter) 쓸 수 있게(Setter) 해주는 것이 Getter와 Setter의 역할입니다.
위 예시에서 다음과 같은 메서드를 작성해보겠습니다.
public class Person {
...
public String getPhoneNumber() {
// private 변수 phoneNumber에 대한 Getter
return this.phoneNumber;
}
public void setPhoneNumber(String phoneNumber) {
// private 변수 phoenNumber에 대한 Setter
this.phoneNumber = phoneNumber;
}
}
이런 식으로 작성을 하면 다음과 같은 작업이 가능합니다.
public class Main {
public static void main(String[] args) {
Person person = new Person();
System.out.println(person.phoneNumber); // 불가능
System.out.println(person.getPhoneNumber); // 가능
person.phoneNumber = "01012345678"; // 불가능
person.setPhoneNumber("01012345678"); // 가능
}
}
그냥 setter를 열어버리면, setter를 사용하는 목적이 사라질 것입니다. 들어오면 안 되는 데이터가 들어온다는 등, 아무런 검증도 없이 값을 바꿀 수 있으면 큰 문제가 생길 수 있습니다.
이에 대한 해결법으로, setter에서 가질 수 있는 값에 대한 검증 로직을 추가하거나 setter 대신 유의미한 메서드로 값을 바꿀 수 있도록 할 수 있습니다.
상속은 기존 클래스의 변수와 메서드를 새로운 클래스가 물려받아 사용할 수 있게 하는 구조입니다. 여기서 기존 클래스를 부모 클래스, 새로운 클래스를 자식 클래스 라고 합니다. 자식 클래스는 부모의 속성과 기능을 그대로 재사용할 수 있고, 필요하다면, 기능을 확장하거나 부모의 메서드를 오버라이딩 할 수도 있습니다.
인터페이스는 implements 키워드를 이용하여 구현을 하였고, 상속은 extends 키워드를 사용하여 구현합니다.
클래스에서 객체 자신의 멤버에 접근할 때 this라는 키워드를 사용한다고 하였습니다. 자식 클래스는 부모 클래스의 변수나 메서드를 가져와서 사용하기 때문에, 변수명이나 메서드 이름에서 혼선이 발생할 수 있습니다(어떤 변수를 부를 때, 자식 클래스의 변수인지, 부모 클래스의 변수인지 모름).
이를 해결하기 위해서 자식 클래스에서 부모 클래스의 멤버에 접근을 할 때에 super 키워드를 붙입니다.
특히, super() 는 부모 클래스의 생성자를 명시적으로 호출할 때 사용하며, 항상 생성자의 첫 번째 줄에 위치해야 합니다.
class Parents {
private String name = "A";
class Child extends Parents {
private String name = "B";
public void example() {
System.out.println(this.name); // "B"
System.out.println(super.name); // "A"
}
public Child {
super(); // 부모의 생성자 먼저 호출
...
}
}
부모로부터 상속받은 메서드를 자식 클래스에서 재정의하는 것을 오버라이딩(overriding)이라고 합니다. 메서드의 이름, 매개변수, 반환타입이 완전히 동일해야 오버라이딩이라고 부를 수 있습니다.
접근 제어자는 부모에서보다 더 강한 수준의 접근 제어자만 사용할 수 있습니다.
여기서, 오버라이딩을 했을 때 실수를 줄이기 위해서 @Override라는 어노테이션을 사용하여 컴파일러에게 확인을 받을 수 있습니다.
class Parents {
public void helloFamily() {
System.out.println("Parents");
}
}
class Child extends Parents {
@Override // 컴파일 시 부모 클래스에 해당 메서드가 존재하는지 확인
protected void helloFamily() { // 더 강한 수준의 접근 제어자로 수정 가능
System.out.println("Child");
} // 자식 객체에서 helloFamily를 호출하면, 이 메서드가 호출됨
@Override
public String helloFamily() {
return "Child";
} // Error! 부모 클래스에 있는 helloFamily 메서드의 반환 타입은 void임
}
추상클래스는 공통 기능을 제공하며, 하위 클래스에 특정 메서드 구현을 강제합니다.
abstract 키워드로 선언하고, 추상 메서드는 반드시 자식 클래스에서 구현해야 합니다.
그리고, 직접 객체로 생성할 수 없습니다.
abstract class Animal {
private String name;
abstract void eat();
public void sleep() {
/* 구현 내용 */
}
}
class Cat extends Animal {
@Override
void eat() {
/* 구현 내용 */
}
}
특정 계층에서 필요한 본질적인 특성만 유지하고, 불필요한 세부사항을 숨기는 것을 추상화라고 합니다.
현실세계는 복잡하지만, 여기서 사물이나 어떤 개념의 공통적이고 중요한 속성과 기능만 추출해서 프로그래밍에 활용되는 것을 의미합니다.
인터페이스 또는 추상 클래스를 통해서 구현하며, 각각의 특징에 맞게 상황에 맞추어 사용할 수 있습니다.
하나의 타입(부모 타입, 인터페이스 등)으로 여러 가지 객체(서브 클래스, 구현체)를 다룰 수 있는 것을 다형성이라고 합니다.
코드에서는 같은 타입으로 보이지만, 실제로는 다양한 객체가 동작할 수 있습니다.
말로 설명하면 이게 뭐지? 싶습니다.
Animal animal = new Cat(); // Animal 타입으로 Cat 객체를 다룬다.
이런 식으로, 타입은 부모 타입으로 두되, 자식 객체를 다루는 것을 의미합니다.
다형성에서 중요한 개념은 형변환입니다.
자식에서 부모 타입으로 변환되는 것을 의미합니다. 이는, 자동적으로 수행이 됩니다.
Animal animal = new Cat(); // cat 객체가 Animal로 형변환
animal.eat(); // 가능
animal.scratch(); // 불가능(cat 객체의 고유 기능)
부모에서 자식 타입으로 변환되는 것입니다. 업캐스팅과 다르게 명시적으로 캐스팅을 해야합니다.
Animal animal = new Cat();
Cat cat = (Cat) animal; // 명시적인 다운캐스팅
cat.scratch(); // 가능
다운 캐스팅이 잘못 되었을 때는, 컴파일러가 잡아주지 못하고 런타임 에러(ClassCastException)이 발생할 수 있습니다.
이를 방지하기 위해 다운 캐스팅 전에는 타입 체킹이 필요합니다.
if (animal instanceof Cat) { // instanceof라는 연산자 활용!
Cat cat = (Cat) animal;
cat.scratch();
} else {
System.out.println("고양이가 아닙니다.");
}
Animal[] animals = {new Cat(), new Dog()}; // 모두 Animal의 자식 클래스이므로 가능
for (Animal animal : animals) {
animal.makeSound(); // 각 객체에 맞는 기능이 실행됨
}
자료 및 코드 출처: 스파르타 코딩클럽