이번 장에서는 Java 에서 객체지향 프로그래밍을 위해서 지원하는 문법과 이를 이용한 프로그래밍 방법을 알아보자.
객체 지향 프로그래밍은 일상생활에서 실제로 사람과 사물이 관계맺는 방식을 표현하기 위한 방법으로 고안된 방법이다.
클래스는 표현하고자 하는 대상의 공통 속성을 한 군데에 정의해 놓은 것이라고 할 수 있다. 즉, 클래스는 객체의 속성을 정의해 놓은 것.
클래스는 멤버 변수(또는 인스턴스 변수), 메소드(함수)를 속성으로 가질 수 있다.
붕어빵을 만드는 틀(붕어빵의 공통된 특성)은 클래스,
붕어빵 틀로부터 만들어진 붕어빵(실제 만들어진 객체)은 인스턴스!
어떠한 클래스로부터 만들어진 객체를 그 클래스의 인스턴스라고 함
인스턴스 예제
public class Main {
static class Person {
String name;
String country;
int age;
}
public static void main(String[] args) {
Person minsoo = new Person();
minsoo.name = "민수";
minsoo.country = "대한민국";
minsoo.age = 10;
Person paul = new Person();
paul.name = "Paul";
paul.country = "America";
paul.age = 10;
Person[] persons = { minsoo, paul };
for(Person cur : persons) {
System.out.println("<자기소개>");
System.out.println("안녕하세요. " + cur.name + " 입니다.");
System.out.println("저는 "+ cur.country + "에서 태어났습니다.");
System.out.println("현재 "+ cur.age + "살 입니다.");
}
}
}
인스턴스의 멤버변수에 접근할 때는 [생성된 인스턴스.멤버변수] 의 형식을 사용하면 된다.
메소드는 어떠한 작업을 수행하는 코드를 하나로 묶어 놓은 것.
int[] heights = new int[5]; // 키가 들어가 있는 배열
initHeight(heights); // 1. 키에 대한 초기화
sortHeight(heights); // 2. 키를 오름차순으로 정렬
printHeight(heights); // 3. 정렬된 키를 출력
메소드를 만들 때, 메소드 안에서 동작하는 내용을 잘 표현할 수 있도록 이름을 잘 지어주면, 메소드 안을 들여다 보지 않고도 한 눈에 코드를 읽어내려갈 수 있어서 좋다. 이것을 readability가 좋다고 표현한다.
이 readability의 기본 품질을 위해서 Java로 메소드를 만들 때 지켜줘야 하는 기본 약속은 다음 두 가지가 있다. 이 약속은 사실상의 표준(defacto - standard)이다.
- 동사로 시작해야한다.
- camel case로 작성해야한다. (첫 단어는 소문자로, 이후 단어의 구분에 따라서 첫 글자만 대문자인 단어가 이어진다. 중간에 띄어쓰기나 특수문자는 들어가지 않는다.)
접근제어자 반환타입 메소드이름 (타입 변수명, 타입 변수명, ...){
수행되어야 할 코드
}
int add(int x, int y) {
int result = x + y;
return result;
}
예제: [CalculationTest.java]
class Calculation {
int add(int x, int y) {
int result = x + y;
return result;
}// 두 값을 더한 결과
int subtract(int x, int y) {
int result = x - y;
return result;
}// 두 값을 뺀 결과
}
public class Main {
public static void main(String[] args) {
Calculation calculation = new Calculation();
int addResult = calculation.add(100, 90);
int subResult = calculation.subtract(90, 70);
System.out.println("두 개를 더한 값은 " + addResult);
System.out.println("두 개를 뺀 값은 " + subResult);
}
}
add
메소드와 subtract
메소드 모두 x와 y변수가 중복되어 사용된 것을 확인할 수 있다. 하지만, 메소드내의 변수는 지역변수로써 메소드 내부에서만 사용할 수 있는 변수다. 즉, 서로 다른 메소드라면 같은 이름의 지역변수를 선언하여 사용해도 됨.정적 메소드는 클래스의 인스턴스 없이도 사용할 수 있는 함수를 말한다.
다음과 같이 선언할 수 있다.
접근제어자 static 반환타입 메소드이름 (타입 변수명,타입 변수명, ...){
수행되어야 할 코드
}
클래스이름.메소드이름(파라미터)
의 형식으로 사용할 수 있다.
정적메소드는 객체의 상태에 독립적이어야 한다. (context-free, stateless) 즉, 파라미터로 받은 값 또는 객체와, 메소드 내에서 사용되는 값 또는 객체, 그리고 다른 정적(static) 리소스만을 이용해서 구현되어야 한다. 해당 클래스의 멤버 변수(인스턴스 변수)와 인스턴스 메소드는 사용할 수 없다.(컴파일 에러)
인스턴스 메소드는 클래스의 객체가 생성되어야 호출될 수 있는 함수를 말한다.
다음과 같이 선언할 수 있습니다.
접근제어자 반환타입 메소드이름 (타입 변수명,타입 변수명, ...){
수행되어야 할 코드
}
객체변수이름.메소드이름(파라미터)
의 형식으로 사용할 수 있다.
인스턴스 메소드는 객체의 리소스에 접근할 수 있습니다. 클래스에 선언된 해당 인스턴스의 멤버 변수의 값을 가져오거나 값을 변경할 수 있고, 인스턴스 메소드를 호출할 수 있다.
생성자는 인스턴스가 생성될 때 사용되는 '인스턴스 초기화 메소드'다. 즉 new
와 같은 키워드로 해당 클래스의 인스턴스가 새로 생성될 때, 자동으로 호출되는 메소드이다. 이 생성자를 이용해서 인스턴스가 생성될 때 수행할 동작을 코드로 짤 수 있는데, 대표적으로 인스턴스 변수를 초기화 하는 용도로 사용한다.
클래스이름 (타입 변수명, 타입 변수명, ...){
인스턴스 생성 될 때에 수행하여할 코드
변수의 초기화 코드
}
생성자에게도 생성자만의 조건이 있다.
1. 생성자의 이름은 클래스명과 같아야 한다.
2. 생성자는 리턴 값이 없다.
public class Main {
static class Person {
String name;
String country;
int age;
public Person(String name, String country, int age) {
this.name = name;
this.country = country;
this.age = age;
}
}
public static void main(String[] args) {
Person minsoo = new Person("민수", "대한민국", 10);
Person paul = new Person("Paul", "America",10);
Person[] persons = { minsoo, paul };
for(Person cur : persons) {
System.out.println("<자기소개>");
System.out.println("안녕하세요. " + cur.name + " 입니다.");
System.out.println("저는 "+ cur.country + "에서 태어났습니다.");
System.out.println("현재 "+ cur.age + "살 입니다.");
}
}
}
💡 생성자에서 사용된
this
는 생성된 객체 자신을 가리키며 생성자의 매개변수의 값을 객체의 해당하는 데이터에 넣어주게 된다.
모든 클래스에는 반드시 하나 이상의 생성자가 있어야 한다!
클래스에 생성자가 1개도 작성이 되어있지 않을 경우, 자바 컴파일러가 기본 생성자를 추가해주기 때문에 우리는 기본 생성자를 작성하지 않고도 편리하게 사용할 수 있다. (기본 생성자는 매개변수와 내용이 없는 생성자)
앞의 <자료형> 챕터에서 배웠던 자료형들처럼 class 에 선언된 변수는 instance 가 생성될 때 값이 초기화(initialize)된다.
이 때, 변수의 선언부나 생성자를 통해서 초기화를 해주지 않는다면, 기본값(default value)를 가진다. 각 자료형마다 기본값이 다르다.
class DefaultValueTest {
byte byteDefaultValue;
int intDefaultValue;
short shortDefaultValue;
long longDefaultValue;
float floatDefaultValue;
double doubleDefaultValue;
boolean booleanDefaultValue;
String referenceDefaultValue;
}
public class Main {
public static void main(String[] args) {
DefaultValueTest defaultValueTest = new DefaultValueTest();
System.out.println("byte default: " + defaultValueTest.byteDefaultValue);
System.out.println("short default: " + defaultValueTest.shortDefaultValue);
System.out.println("int default: " + defaultValueTest.intDefaultValue);
System.out.println("long default: " + defaultValueTest.longDefaultValue);
System.out.println("float default: " + defaultValueTest.floatDefaultValue);
System.out.println("double default: " + defaultValueTest.doubleDefaultValue);
System.out.println("boolean default: " + defaultValueTest.booleanDefaultValue);
System.out.println("reference default: " + defaultValueTest.referenceDefaultValue);
}
}
실행 결과
byte default: 0 // 1byte 를 구성하는 8개의 bit가 모두 0이라는 뜻.
short default: 0
int default: 0
long default: 0
float default: 0.0
double default: 0.0
reference default: null
이미지 출처 : https://medium.com/@smagid_allThings/uml-class-diagrams-tutorial-step-by-step-520fd83b300b
상속을 보여주는 UML Class Diagram이다. 동물의 하위 계층으로 오리, 물고기, 얼룩말이 존재한다. 이렇게 계층적 구조를 만들어 보자!
UML(Unified Modeling Language) 이란?
- 소프트웨어 시스템을 시각적으로 표현하기 위한 표준화된 모델링 언어
- 객체지향 설계를 기반으로 시스템의 구조 및 동작을 문서화하는 데 사용됨
- 소프트웨어 아키텍처, 비즈니스 프로세스, 데이터 모델링 등 다양한 분야에서 활용
UML 클래스 다이어그램 (UML Class Diagram)
- 클래스 간의 관계를 시각적으로 표현하는 UML 다이어그램 중 하나
- 객체지향 프로그래밍에서 클래스 구조를 설계하고, 속성(attribute) 및 메서드(method)를 정의
- 상속, 연관, 집합, 포함 관계 등을 나타낼 수 있음
상속이란 기존의 클래스를 재사용하는 방식 중의 하나다.
상속은 extends를 이용하여 사용할 수 있다.
class Animal{}
class Duck extends Animal{}
class Fish extends Animal{}
class Zebra extends Animal{}
위 그림에서 Animal 는 부모 클래스, 조상 클래스(Parent Class)라고 부른다. Duck,Fish,Zebra 클래스는 자식 클래스, 자손 클래스(Child Class)라고 부른다.
package com.fastcampus.de.java.clip11_4;
public class Main {
static class Animal {
String name;
public void cry() {
System.out.println(name + " is crying.");
}
}
static class Dog extends Animal {
Dog(String name) {
this.name = name;
}
public void bark() {
System.out.println(name + "(" + breed + ") is barking.");
}
}
public static void main(String[] args) {
Dog dog = new Dog("코코", "허스키");
dog.cry();
dog.bark();
Animal animal = dog;
animal.cry();
// animal.bark(); 실제 객체는 dog와 같더라도, Animal 타입으로 선언한 이상, Dog 타입에 있는 함수는 호출할 수 없습니다.
}
}
부모 클래스에 있는 필드나 메소드, 생성자를 자식 클래스에서 참조하여 사용하고 싶을 때 사용하는 키워드다.
Super 에 대한 추가 설명 : https://www.tcpschool.com/java/java_inheritance_super
public class Main {
static class Animal {
String name;
Animal(String name) {
this.name = name;
}
public void cry() {
System.out.println(name + " is crying.");
}
}
static class Dog extends Animal {
String breed;
Dog(String name, String breed) {
super(name);
this.breed = breed;
}
public void bark() {
System.out.println(name + "(" + breed + ") is barking.");
}
}
public static void main(String[] args) {
Dog dog = new Dog("코코", "허스키");
dog.cry();
dog.bark();
Animal animal = dog;
animal.cry();
// animal.bark(); 실제 객체는 dog와 같더라도, Animal 타입으로 선언한 이상, Dog 타입에 있는 함수는 호출할 수 없습니다.
}
}
부모 클래스인 Animal에 name을 받는 생성자가 있다.
Animal을 상속받는 Dog 클래스에 기본 생성자를 만들면 부모 클래스의 생성자와 맞지 않아 오류가 발생한다.
따라서 최소한 부모와 같은 파라미터를 받는 생성자를 만들어야 하고, 추가로 더 많은 값을 받는 생성자도 정의할 수 있다.
super는 부모 클래스를 의미하며, super(name)은 부모 클래스의 생성자를 호출하는 역할을 한다.
부모 클래스에 기본 생성자 외에 다른 생성자가 있다면, 자식 클래스에는 최소한 그에 맞는 생성자가 하나 있어야 한다.
한 클래스 내에 동일한 이름의 메소드를 여러개 정의하는 것
int add(int x, int y, int z) {
int result = x + y + z;
return result;
}
long add(int a, int b, long c) {
long result = a + b + c;
return result;
}
int add(int a, int b) {
int result = a + b;
return result;
}
//오버로딩의 조건에 부합하는 예제다.
오버로딩이 아닌 경우는
리턴 타입만 다르고 입력 파라미터의 타입이 같은 경우다.
같은 이름과 동일한 파라미터 리스트를 가진 함수가 두 개 존재하면 컴파일 오류가 발생한다
함수를 호출할 때는 객체를 생성한 후, 함수 이름과 전달하는 파라미터에 따라 어떤 함수가 실행될지 결정된다.
리턴 타입은 함수 실행 후에야 알 수 있기 때문에, 리턴 타입이 다르다고 해서 오버로딩이 성립하지 않는다.
또한, int와 long처럼 서로 자동 변환(Auto Casting)이 가능한 타입의 경우, 같은 int 결과를 long 변수에 저장할 수 있다.
자바에서는 박싱(Boxing), 언박싱(Unboxing) 등의 개념이 적용되어 특정 타입 간 변환이 자동으로 이루어지기 때문에, 리턴 타입만 다른 메소드는 서로 명확히 구분되지 않는다.
결국 오버로딩의 조건은 메소드 이름이 같더라도, 파라미터의 타입이나 개수가 달라야 한다는 점이다
int add(int x, int y, int z) {
int result = x + y + z;
return result;
}
long add(int a, int b, int c) {
long result = a + b + c;
return result;
}
// 반환타입은 다르지만 매개변수의 자료형과 개수는 같기에 오버로딩이 아니다.
부모 클래스로부터 상속받은 메소드를 재정의 하는 것.
상속받은 메소드를 그대로 사용하기도 하지만, 필요에 의해 변경해야할 경우 오버라이딩함.
이름
이 같아야 한다매개변수
가 같아야 한다반환타입
이 같아야 한다그러니까 완전히 똑같은 메소드를 재정의한다. 고 보면 됨.
public class Main {
static class Animal {
String name;
Animal(String name) {
this.name = name;
}
public void cry() {
System.out.println(name + " is crying.");
}
}
static class Giraffe extends Animal {
Giraffe(String name) {
super(name);
}
@Override
public void cry() {
System.out.println(name + " cannot cry.");
}
}
public static void main(String[] args) {
Animal giraffe = new Giraffe("기린이");
giraffe.cry();
}
}
Overriding을 하더라도 super 키워드를 이용해 부모 클래스에 선언된 메소드를 그대로 호출할 수 있다.
public class Main {
static class Animal {
String name;
Animal(String name) {
this.name = name;
}
public void cry() {
System.out.println(name + " is crying.");
}
}
static class Giraffe extends Animal {
Giraffe(String name) {
super(name);
}
@Override
public void cry() {
super.cry();
System.out.println(name + " cannot cry.");
}
}
public static void main(String[] args) {
Animal giraffe = new Giraffe("기린이");
giraffe.cry();
}
}
super.cry()를 호출하면 부모 클래스 Animal의 cry() 메소드가 실행되어 "기린이 is crying."이 출력된다.
이후 오버라이딩된 cry() 메소드의 "기린이 cannot cry."가 출력된다.
@Override 어노테이션은 없어도 코드가 실행되지만, 추가하는 것이 좋다.
이걸 보면 해당 메소드가 부모의 메소드를 재정의한 것임을 한눈에 알 수 있기 때문이다. 즉, 부모 클래스의 동작과 다를 수 있으니 주의해야 한다는 점을 명확히 해준다.
객체지향의 캡슐화 개념에서는 객체의 상태가 외부 변경에 취약하지 않도록 보호해야 한다.
하지만 Override는 부모 클래스의 동작을 변경하는 것이므로, 캡슐화를 어느 정도 깨뜨린다고 볼 수 있다.
그럼에도 불구하고 자바에서는 상속을 통해 Override를 허용하고 있다.
오버로딩 : 기존에 없는 새로운 메소드를 정의하는 것
오버라이딩 : 부모 클래스에 있는 메소드를 재정의하는 것
접근 제어자는 멤버 변수/함수 혹은 클래스에 사용되며 외부에서의 접근을 제한하는 역할을 한다.
(intelliJ에서 코드를 짤 때, 접근제어자가 없음면 기본이 package private라고 해서, 같은 패키지 안에 있는 친구들은 클래스의 객체를 가지고 있다면 해당 함수에 접근할 수 있다.)
private → default → protected → public
의 순서대로 접근 범위가 넓어짐
접근제어자 예제를 위한 첫번째 패키지를 만든다.
public class AccessModifierTest {
private void messageInside() {
System.out.println("This is private modifier");
}
void messageDefault() {
messageInside();
System.out.println("This is default(package-private) modifier");
}
protected void messageProtected() {
messageInside();
System.out.println("This is protected modifier");
}
public void messageOutside() {
messageInside();
System.out.println("This is public modifier");
}
}
첫 번째 패키지 내부에 Anonymous 클래스를 만든다. AccessModifierTest 클래스의 객체를 만들고 각 함수를 호출가능한지 확인한다.
public class Anonymous {
public void call() {
AccessModifierTest accessModifierTest = new AccessModifierTest();
// accessModifierTest.messageInside(); compile error
accessModifierTest.messageDefault();
accessModifierTest.messageProtected();
accessModifierTest.messageOutside();
}
public static void main(String[] args) {
Anonymous anonymous = new Anonymous();
anonymous.call();
}
}
두 번째 패키지를 만든다.
두 번째 패키지 내부에 Anonymous 클래스를 만든다. AccessModifierTest 클래스의 객체를 만들고 각 함수를 호출가능한지 확인한다.
import com.fastcampus.de.java.clip11_5.AccessModifierTest;
public class Anonymous {
public void call() {
AccessModifierTest accessModifierTest = new AccessModifierTest();
// accessModifierTest.messageInside(); compile error
// accessModifierTest.messageDefault();
// accessModifierTest.messageProtected();
accessModifierTest.messageOutside();
}
}
두 번째 패키지 내부에 AccessModifierTest를 상속( extends
) 받은 Child 클래스를 만든다. Child 클래스의 함수 내에서 this 키워드를 이용해서 AccessModifierTest에 선언한 각 함수를 호출 가능한지 확인한다.
import com.fastcampus.de.java.clip11_5.AccessModifierTest;
public class Child extends AccessModifierTest{
public void call() {
// this.messageInside();
// this.messageDefault();
this.messageProtected();
this.messageOutside();
}
public static void main(String[] args) {
Child child = new Child();
child.call();
}
}
캡슐화(encapsulation)
라고 하며, 접근 제어자는 캡슐화가 가능하게 돕는 도구다.추상클래스는 추상메소드를 선언할 수 있는 클래스를 의미한다. 또한 추상클래스는 클래스와는 다르게 상속받는 클래스 없이 그 자체로 인스턴스를 생성할 수는 없다.
대신 타입으로서는 쓸 수 있다.
추상메소드는 메소드 시그니처(method signature)의 설계만 되어있으며 수행되는 코드 ({}
)에 대해서는 작성이 안된 메소드다.
abstract 리턴타입 메소드이름();
abstract class Bird {
private int x, y, z;
void fly(int x, int y, int z) {
printLocation();
System.out.println("이동합니다.");
this.x = x;
this.y = y;
if (flyable(z)) {
this.z = z;
} else {
System.out.println("그 높이로는 날 수 없습니다");
}
printLocation();
}
abstract boolean flyable(int z);
public void printLocation() {
System.out.println("현재 위치 (" + x + ", " + y + ", " + z + ")");
}
}
class Pigeon extends Bird {
@Override
boolean flyable(int z) {
return z < 10000;
}
}
class Kiwi extends Bird {
@Override
boolean flyable(int z) {
return false;
}
}
public class Main {
public static void main(String[] args) {
Bird pigeon = new Pigeon();
Bird kiwi = new Kiwi();
System.out.println("-- 비둘기 --");
pigeon.fly(1, 1, 3);
System.out.println("-- 키위새 --");
kiwi.fly(1, 1, 3);
System.out.println("-- 비둘기 --");
pigeon.fly(3, 3, 30000);
}
}
fly(x, y, z) 함수는 Bird 를 상속받는 모든 클래스에서 동일한 동작을 한다. 다만, 그 안에서 호출된 flyable(z) 의 동작만 그것을 구현하는 자식 클래스에서 구현한대로 동작하는 것이다.
키위새(kiwi)는 새이지만 전혀 날 수가 없다. 그래서 키위새의 flyable() 은 항상 false 를 리턴해서 언제나 x,y 좌표로만 움직인다. 반면에, 비둘기(pigeon)는 일정 높이까지는 날아갈 수 있기 때문에 그 기준(여기서는 10000)이 되기 전까지는 z좌표로도 움직일 수 있다. 이것을 새의 종류마다 중복코드 없이 구현하려면 추상클래스와 추상메소드를 이용해서 이렇게 구현할 수 있다.
이렇게 코드를 짜면, 중복코드가 없으면서도 새의 종류마다 주어진 위치까지 날 수 있는지를 판단할 수 있는 유연성을 허용하며 구현할 수 있다.
Tip: interface의 메소드 또는 abstract class 의 abstract method 처럼 구현하는 클래스에서 직접 구현해야하는 경우 IntelliJ IDEA에서
command
+N
(window는alt
+insert
)을 눌러서implement methods
를 선택하면 자동으로 코드완성이 된다. 혹은 class 선언 부분에 빨간줄이 그어진다면alt
+enter
로도 추천이 가능하다.
인터페이스는 객체가 가져야 할 행동(메소드)의 형식만 정의하고 실제 구현(동작)은 구현(implements)한 클래스에게 맡기는 구조다.
interface 인터페이스{
리턴타입 메소드이름();
default 리턴타입;
}
class 클래스 implements 인터페이스 {
@Override
public 리턴타입 메소드이름() {
// TODO implements
}
}
9.7.2 인터페이스 예제
public interface Bird {
String STATIC_VARIABLE = "STATIC";
void fly(int x, int y, int z);
default void printBreed() {
System.out.println("나는 새 중에 " + getBreed() + " 입니다.");
}
String getBreed();
static void staticMethod() {
System.out.println("This is static method");
}
abstract void abstractMethod();
}
public interface Pet {
String getHome();
}
public class Pigeon implements Bird, Pet{
private int x, y, z;
@Override
public void fly(int x, int y, int z) {
System.out.println("이동합니다.");
this.x = x;
this.y = y;
this.z = z;
printLocation();
}
@Override
public String getBreed() {
return "비둘기";
}
@Override
public void abstractMethod() {
System.out.println("this is abtract method implemented from Pigeon");
}
public void printLocation() {
System.out.println("현재 위치 (" + x + ", " + y + ", " + z + ")");
}
@Override
public String getHome() {
return "도곡동";
}
}
public class Main {
public static void main(String[] args) {
Bird bird = new Pigeon();
bird.fly(1, 2, 3);
// bird.printLocation(); // compile error
bird.printBreed();
bird.abstractMethod();
Bird.staticMethod();
System.out.println(Bird.STATIC_VARIABLE);
}
}
@Override
앞에서 배웠던 메소드 오버라이딩의 개념이 abstract method, interface의 함수를 구현하는 데에도 사용됨.
참고 링크 : https://en.wikipedia.org/wiki/Composition_over_inheritance
상속(Inheritance)이 캡슐화를 깨뜨리는 이유는 부모 클래스의 구현이 자식 클래스에 강하게 결합되기 때문이다.
그래서 "Composition over Inheritance"를 권장
상속(Inheritance) 대신 합성(Composition)을 사용하면 불필요한 결합도를 낮추고 유연한 설계가 가능함.
즉, 객체를 상속하는 대신 필요한 기능을 별도 클래스로 분리하고 이를 객체로 포함(참조)하는 방식이 더 바람직하다.