객체 지향 프로그래밍

맥모닝·2023년 12월 7일
0

CS

목록 보기
1/8
post-thumbnail

객체 지향 프로그래밍(Object Oriented Programming)

  • 컴퓨터 프로그램을 어떤 데이터를 입력받아 순서대로 처리하고 결과를 도출하는 명령어들의 목록으로 보는 시각(명령형 프로그래밍)에서 벗어나 여러 독립적인 부품들의 조합, 즉 객체들의 유기적인 협력과 결합으로 파악하고자 하는 컴퓨터 프로그래밍의 패러다임이다.
  • 다형성, 캡슐화, 추상화, 상속을 지원한다.

객체 지향적으로 소프트웨어를 설계하다

  • 어떤 프로그램의 일부분에 해당하는 작은 부품, 즉 객체를 먼저 만들고 이렇게 만들어진 여러 객체들을 조립해서 하나의 완성된 프로그램을 만드는 프로그래밍 방법론을 뜻한다.

객체 지향 프로그래밍의 장점

1. 프로그램을 보다 유연하고 변경이 용이하게 만들 수 있다.

  • 소프트웨어를 설계할 때 객체 지향적 원리를 잘 적용해 둔 프로그램은 부품들이 각자의 독립적인 역할을 가지기 때문에 코드의 변경을 최소화하고 유지보수를 하는 데 유리하다.
    • Ex) 컴퓨터 부품을 교체할 때, 해당하는 부품만 쉽게 교체하고 나머지 부품들은 건드리지 않아도 되는 것

2. 코드의 재사용을 통해 반복적인 코드를 최소화하고, 코드를 최대한 간결하게 표현할 수 있다.

객체지향 설계 과정

  1. 요구사항 (제공해야 할 기능)을 찾고 세분화한다. 그리고 기 기능을 알맞게 객체로 할당한다.
  2. 기능을 구현하는 데에 필요한 데이터를 객체에 추가한다.
  3. 해당 데이터를 이용하는 기능을 구현한다. (기능은 최대한 캡슐화)
  4. 객체 간에 어떻게 메소드 호출을 주고받을지 결정한다.

객체

사전적 정의 : 인식 가능한 물체/물건을 의미하며, 객체들은 각자의 고유한 속성과 동작을 갖고 있다.

소프트웨어 관점 : 서로 연관있는 변수(속성: property)들을 묶어놓은 데이터 덩어리

객체의 상태는 변수들을 통해 나타내며, 객체의 행동은 메서드를 통해 정의된다.

class Car {
    // 속성 정의
    var company: String = ""
    var model = ""
    var color = ""
    var wheels = 0
    var isConvertible = false

    // 기능 정의
    fun startEngine() {
        println("시동을 건다")
    }

    fun moveForward() {
        println("자동차가 앞으로 전진한다")
    }

    fun moveBackward() {
        println("자동차가 뒤로 후진한다")
    }
}

객체 지향 프로그래밍의 4가지 특징

객체 지향 프로그래밍은 우리가 보고 인지하는 실제 세계를 흉내 내어 가장 기본적인 단위인 객체들을 만들고, 그것들 간의 유기적인 상호작용을 규정하여 프로그램을 발전시키는 프로그래밍 방법론이다.

또한 객체 지향적 설계를 통해 소프트웨어를 개발하면 코드의 재사용을 통해 반복적인 코드를 최소화하고, 보다 유연하고 변경이 용이한 프로그램을 만들 수 있다.

💡 Class : 객체의 설계도로 사용되며, 객체의 상태와 행동을 정의하는데 필드와 메서드를 포함할 수 있다. 상속을 통해 다른 클래스로부터 특성을 상속받을 수 있으며, 단일 상속만 허용한다.

💡 Interface : 일종의 추상된 틀로, 클래스에서 구현해야 하는 메서드들의 집합을 정의한다. 다른 클래스들이 해당 인터페이스를 구현함으로써, 공통된 행동이나 규약을 정의하고 일관성 있는 구조를 갖도록 도와준다. 클래스는 하나의 클래스만을 상속받을 수 있지만, 인터페이스는 여러 개를 구현할 수 있다.

WHEN) 다중 상속이 필요할 때, 클래스 간의 공통된 동작을 정의할 때, 구현체에서 특정 메서드가 반드시 존재하도록 강제할 때

💡 Abstract : 다중 상속을 지원하지 않고, 구현이 있는 메서드와 구현이 없는 메서드를 모두 가질 수 있으며, abstract 키워드가 붙은 추상 메서드만 하위 클래스에서 반드시 구현이 필요하다.

WHEN) 공통된 기능을 여러 클래스에서 공유할 때, 클래스 계층 구조를 형성할 때, 실제 인스턴스를 생성하지 않고, 클래스의 설계 목적으로만 사용할 때

추상화(Abstration)

사전적 정의 : 사물이나 표상을 어떤 성질, 공통성, 본질에 착안하여 그것을 추출하여 파악하는 것

핵심 : 불필요한 세부 사항들은 제거하고 가장 본질적이고 공통적인 부분만을 추출하여 표현하는 것

소프트웨어 관점 : 복잡성을 다루는 데 도움을 주는 방법으로, 공통 필드와 메서드를 정의하고 서브 클래스에서 이를 구체화함으로써 객체의 공통적인 특징을 표현하고 필요한 구체적인 동작을 제공한다.

  • 추상화를 구현할 수 있는 문법 요소 : 추상 클래스(abstract class)와 인터페이스(interface)
  • 인터페이스는 어떤 객체의 역할만을 정의하여 객체들 간의 관계를 보다 유연하게 연결하는 역할을 담당한다.
    • 추상 메서드나 상수를 통해서 어떤 객체가 수행해야 하는 핵심적인 역할만을 규정해두고, 실제 구현은 해당 인터페이스를 구현하는 각각의 객체들에서 하도록 프로그램을 설계하는 것을 의미한다.
// 자동차와 오토바이의 공통적인 기능 추출하여 이동 수단 인터페이스에 정의
interface Vehicle {
    abstract fun start()
    fun moveForward()
    fun moveBackward()
}

// Car 클래스 : 이동수단을 구체화한 자동차 클래스
class Car : Vehicle {
    override fun moveForward() {
        println("자동차가 앞으로 전진한다")
    }

    override fun moveBackward() {
        println("자동차가 뒤로 후진한다")
    }
}

// MotorBike 클래스
class MotorBike : Vehicle {
    override fun moveForward() {
        println("오토바이가 앞으로 전진한다")
    }

    override fun moveBackward() {
        println("오토바이가 뒤로 후진한다")
    }
}
  • Vehicle 인터페이스를 구현한 구현체, Car와 MotorBike 클래스에서 인터페이스에 정의한 역할을 각각의 클래스의 맥락에 맞게 구현하고 있다.
  • 클래스 모두 전진과 후진의 기능을 공통적으로 가지지만, 차는 차의 시동을 걸어야 하고, 오토바이는 오토바이의 시동을 걸어야 하기 때문에 이러한 구현은 각 클래스에 따라 달라야 할 것이다.
  • 객체 지향 프로그래밍에서는 보다 유연하고 변경에 열려있는 프로그램을 설계하기 위해 역할과 구현을 분리하는 데, 여기서 역할에 해당하는 부분이 인터페이스를 통해 추상화될 수 있다.

상속

  • 기존의 클래스에 기능을 추가하거나 재정의하여 새로운 클래스를 정의하는 것
  • 클래스 간 공유될 수 있는 속성과 기능들을 상위 클래스로 추상화시켜 해당 상위 클래스로부터 확장된 여러 개의 하위 클래스들이 모두 상위 클래스의 속성과 기능들을 간편하게 사용할 수 있도록 한다.
  • 클래스들 간 공유하는 속성과 기능들을 반복적으로 정의할 필요 없이 딱 한번만 정의해두고 간편하게 재사용할 수 있어 반복적인 코드를 최소화하고 공유하는 속성과 기능에 간편하게 접근하여 사용할 수 있도록 한다.
  • 빨간색으로 표시된 속성과 기능들은 자동차와 오토바이의 공통적인 부분들이고, 푸른색으로 표시된 부분들은 그렇지 않은 부분들이다.
class Car {
    var model = ""
    var color = ""
    var wheels = 0
    
    // Car 클래스 고유의 속성
    var isConvertible = false
    
    fun moveForward() {
        println("앞으로 전진한다")
    }
    
    fun moveBackward() {
        println("뒤로 후진한다")
    }
    
    // Car 클래스 공유의 기능
    fun openWindow() {
        println("모든 창문을 연다")
    }
}
class MotorBike {
    var model = ""
    var color = ""
    var wheels = 0
    
    // MotorBike 클래스 고유의 속성
    var isRaceable = false
    
    fun moveForward() {
        println("앞으로 전진한다")
    }
    
    fun moveBackward() {
        println("뒤로 후진한다")
    }
    
    // MotorBike 클래스 공유의 기능
    fun stunt() {
        println("오토바이로 묘기를 부린다")
    }
}
  • 각각의 클래스마다 속성으로 model, color, wheels 기능으로 moveForward()와 moveBackward()가 계속 반복되고 있다.
    • 하나의 코드에서 변경 사항이 일어나면, 해당 코드의 변경 사항을 다른 클래스에서도 일일이 수정해주어야 하는 어려움이 있다.
  • 추상화와 상속을 활용하여 코드 재정의
// 추상화를 통한 상위클래스 정의
open class Vehicle() {
    var model = ""
    var color = ""
    var wheels = 0

    open fun moveForward() {
        println("전진한다")
    }

    fun moveBackward() {
        println("후진한다")
    }
}

class Car() : Vehicle() {
    var isConvertible = false

    fun openWindow() {
        println("모든 창문을 연다")
    }
}

class MotorBike : Vehicle() {
    var isRaceable = false
    
    // 메서드 오버라이딩을 사용하여 상위 클래스의 기능을 재정의
    override fun moveForward() {
        println("오토바이가 앞으로 전진한다")
    }

    fun stunt() {
        println("오토바이가 묘기를 부린다")
    }
}

fun main() {
    val car = Car()
    val motorBike = MotorBike()

    car.model = "테슬라"
    car.color = "빨강색"

    println("나의 자동차는 " + car.color + " " + car.model + "입니다")

    car.moveForward()
    motorBike.moveForward()
    motorBike.moveBackward()
}

// 나의 자동차는 빨강색 테슬라입니다
// 전진한다
// 오토바이가 앞으로 전진한다
// 후진한다
  • Car와 MotorBike 클래스의 공통적인 속성과 기능들을 추출(추상화)하여 Vehicle 클래스(상위 클래스)에 정의하였고, 상속을 통해 각각의 하위 클래스로 확장하여 해당 기능과 속성들을 매번 반복적으로 정의해야 하는 번거로움을 제거했다.
  • 또한, 공통적인 코드의 변경이 있는 경우 상위 클래스에서 단 한 번의 수정으로 모든 클래스에 변경 사항이 반영될 수 있도록 만들었다.

상속과 인터페이스 비교

  • 공통점 : 양자 모두 상위 클래스-하위 클래스의 관계를 전제하면서 공통적인 속성과 기능을 공유할 수 있다.
  • 차이점 : 상속의 경우 상위 클래스의 속성과 기능들을 하위 클래스에서 그대로 받아 사용하거나 오버라이딩을 통해 선택적으로 재정의하여 사용할 수 있는 반면, 인터페이스를 통한 구현은 반드시 인터페이스에 정의된 추상 메서드의 내용이 하위 클래스에서 정의되어야 한다.
  • 결론 : 상속 관계의 경우 인터페이스를 사용하는 구현에 비해 추상화의 정도가 낮다고 할 수 있다.
  • 인터페이스가 역할에 해당하는 껍데기만 정의해두고, 하위 클래스에서 구체적인 구현을 하도록 갖에하는 것에 비해, 상속 관계의 경우 상황에 따라 모든 구체적인 내용들을 정의해두고 하위 클래스에서는 그것을 단순히 가져다가 재사용할 수 있다.

다형성

사전적 정의 : 어떤 객체의 속성이나 기능이 상황에 따라 여러 가지 형태를 가질 수 있는 성질

소프트웨어 관점 : 한 타입의 참조변수(특정 클래스나 인터페이스의 타입으로 선언된 변수)를 통해 여러 타입의 객체(해당 클래스나 인터페이스의 하위 클래스의 객체)를 참조할 수 있도록 만든 것을 의미한다.

하나의 인터페이스나 부모 클래스를 통해 여러 구현 클래스나 자식 클래스를 다룰 수 있는 기능으로, 같은 메서드 호출을 통해 서로 다른 클래스의 메서드를 실행할 수 있다.

메서드 오버라이딩과 메서드 오버로딩을 통해 구현한다.

open class Animal {
    open fun makeSound() {
        println("Some generic sound")
    }
}

class Dog : Animal() {
    override fun makeSound() {
        println("Bark!")
    }
}

class Cat : Animal() {
    override fun makeSound() {
        println("Meow!")
    }
}

fun main() {
    // 한 타입의 참조변수를 통해 여러 타입의 객체를 참조
    // 즉, 상위 클래스 타입의 참조변수로 하위 클래스의 객체를 참조
    val animal1: Animal = Dog()
    val animal2: Animal = Cat()

    // 같은 메서드 호출을 통해 서로 다른 클래스의 메서드를 실행
    animal1.makeSound() // Bark!
    animal2.makeSound() // Meow!
}
// Vehicle 인터페이스
interface Vehicle {
    abstract fun start()
    fun moveForward()
    fun moveBackward()
}

class Car : Vehicle {
    override fun moveForward() {
        println("자동차가 앞으로 전진한다")
    }

    override fun moveBackward() {
        println("자동차가 뒤로 후진한다")
    }
}
  • 메서드 오버라이딩을 통해 같은 이름의 moveForward()와 moveBackward()를 각각의 클래스의 맥락에 맞게 재정의하여 사용할 수 있다.
    • 즉, 같은 이름의 메서드가 상황에 따라 다른 역할을 수행하는 것
  • 메서드 오버로딩 : 하나의 클래스 내에서 같은 이름의 메서드를 여러 개 중복하여 정의하는 것

다형성 방식이 유용한 이유

  • 여러 종류의 객체를 배열로 다루는 일이 가능해진다.
fun main() {
    // val vehicles: Array<Vehicle> = arrayOf(Car(), MotorBike())
    val vehicles: Array<Vehicle?> = arrayOfNulls(2)
    vehicles[0] = Car()
    vehicles[1] = MotorBike()

    for (vehicle in vehicles) {
        // println(vehicle::class)
        println(vehicle?.javaClass)
    }
}
  • Vehicle 타입의 객체 배열을 생성해주면, 해당 타입의 참조 변수는 Vehicle 클래스와 상속 관계에 있는 모든 하위 클래스들을 그 안에 담아줄 수 있다.
  • 하나의 타입만으로 여러 가지 타입의 객체를 참조할 수 있어 보다 간편하고 유연하게 코드를 작성하는 것이 가능해진다.
class Driver {
    fun drive(car: Car) {
        car.moveForward()
        car.moveBackward()
    }
    
    fun drive(motorBike: MotorBike) {
        motorBike.moveForward()
        motorBike.moveBackward()
    }
}

fun main() {
    val car = Car()
    val motorBike = MotorBike()
    val driver = Driver()
    
    driver.drive(car)
    driver.drive(motorBike)
}

// 전진한다
// 후진한다
// 오토바이가 앞으로 전진한다
// 후진한다
  • Driver 클래스는 매개변수로 자동차나 오토바이 객체를 전달받아 운전하는 것이다. 이렇게 하나의 객체가 다른 객체의 속성과 기능에 접근하여 어떤 기능을 사용할 때 A클래스는 B클래스에 의존한다라고 표현한다.
  • 즉 Driver 클래스와 다른 두 개의 클래스(Car, MotorBike)가 서로 직접적인 관계를 가지고 있어 객체들 간의 결합도가 높다고 표현한다.

문제점 : 결합도가 높은 상태는 객체 지향적인 설계를 하는데 매우 불리하다. 추상화, 상속, 그리고 다형성의 특성을 활용하여 프로그래밍을 설계할 때 역할과 구현을 구분하여 객체들 간의 직접적인 결합을 피하고, 느슨한 관계 설정을 통해 보다 유연하고 변경이 용이한 프로그램을 설계한다.

interface Vehicle {
    fun moveForward()
    fun moveBackward()
}

class Car : Vehicle {
    override fun moveForward() {
        println("자동차가 앞으로 전진한다")
    }

    override fun moveBackward() {
        println("자동차가 뒤로 후진한다")
    }
}

class MotorBike : Vehicle {
    override fun moveForward() {
        println("오토바이가 앞으로 전진하다")
    }

    override fun moveBackward() {
        println("오토바이가 뒤로 후진한다")
    }
}

class Driver {
    fun drive(vehicle: Vehicle) {
        vehicle.moveForward()
        vehicle.moveBackward()
    }
}

fun main() {
    val car = Car()
    val motorBike = MotorBike()
    val driver = Driver()
    
    driver.drive(car)
    driver.drive(motorBike)
}

// 자동차가 앞으로 전진한다
// 자동차가 뒤로 후진한다
// 오토바이가 앞으로 전진하다
// 오토바이가 뒤로 후진한다
  • 이제 Driver 클래스는 더 이상 각각의 클래스 내부의 변경이나 다른 객체가 새롭게 교체되는 것을 신경 쓰지 않아도 인터페이스에만 의존하여 수정이 있을 때마다 코드 변경을 하지 않아도 된다.

문제점 : 객체를 생성할 때 객체에 직접적으로 의존하고 있어서, 해당 객체를 다른 객체로 변경할 시 코드의 변경이 불가피하다. 즉, 객체 간 결합도가 높다.

  • 해결책 : 의존관계 주입(dependency injection)

캡슐화

  • 클래스 안에 서로 연관있는 속성과 기능들을 하나의 캡슐로 만들어 데이터를 외부로부터 보호하는 것
  • 데이터 보호 : 외부로부터 클래스에 정의된 속성과 기능들을 보호
  • 데이터 은닉 : 내부의 동작을 감추고 외부에는 필요한 부분만 노출
  • 목적 : 외부로부터 클래스에 정의된 속성과 기능들을 보호하고, 필요한 부분만 외부로 노출될 수 있도록 하여 각 객체 고유의 독립성과 책임 영역을 안전하게 지키려고 한다.

필드와 메서드를 하나의 단위로 묶어서 외부에서 직접적인 접근을 제한하고 데이터를 보호하기 위한 것으로, 필요한 경우에만 메서드를 통해 데이터를 조작할 수 있다.

정보 은닉화 : 객체의 외부와 내부를 구분하고, 외부로부터의 접근을 제어하여 객체를 안정적으로 사용하고 관리할 수 있도록 도와준다.

접근제어자와 getter/setter 메서드를 통해 구현한다.

접근제어자(access modifiers)

  • 클래스 또는 클래스 내부의 멤버들에 사용되어 해당 클래나 멤버들을 외부에서 접근하지 못하도록 접근을 제한하는 역할
변경자 클래스 멤버 최상위 선언
public(기본 가시성임) 모든 곳에서 볼 수 있다. 모든 곳에서 볼 수 있다.
internal 같은 모듈 안에서만 볼 수 있다. 같은 모듈 안에서만 볼 수 있다.
protected 하위 클래스 안에서만 볼 수 있다. (최상위 선언에 적용할 수 없음)
private 하위 클래스 안에서만 볼 수 있다. 같은 파일 안에서만 볼 수 있다.
package package1

open class SuperClass {
    private val a = 1
    protected val b = 2
    internal val c = 3
    public val d = 4

    open fun printEach() {
        println(a)
        println(b)
        println(c)
        println(d)
    }
}

fun main() {
    val superClass = SuperClass()

    // println(superClass.a)
    // println(superClass.b)
    println(superClass.c) // 3
    println(superClass.d) // 4
}
  • 다른 패키지의 Test2 클래스
package package2

import package1.SuperClass

class SubClass : SuperClass() {
    override fun printEach() {
        // println(a)
        println(b)
        println(c)
        println(d)
    }
}

fun main() {
    val parent = SuperClass()

    // println(parent.a)
    // println(parent.b)
    println(parent.c)
    println(parent.d)
}

getter/setter 메서드

class Car(private val name: String, private val color: String) {
    fun startEngine() {
        println("시동을 겁니다")
    }
    
    fun moveForward() {
        println("자동차가 앞으로 전진한다")
    }
    
    fun openWindow() {
        println("모든 창문을 연다")
    }
}

class Driver(private val name: String, private val car: Car) {
    fun drive() {
        car.startEngine()
        car.moveForward()
        car.openWindow()
    }
}

fun main() {
    val car = Car("테슬라 모델X", "레드")
    val driver = Driver("김코딩", car)
    
    driver.drive()
}

// 시동을 겁니다
// 자동차가 앞으로 전진한다
// 모든 창문을 연다

문제점 : drive() 메서드가 호출되었을 때 Car 클래스의 메서드들이 순차적으로 실행되고 있다.

  • WHY) Car 클래스의 3가지 메서드들에 어떤 변경이 생겼을 때 해당 메서드들을 사용하고 있는 Driver 클래스의 drive() 메서드의 수정이 불가피하다.
    • 즉, Driver 클래스가 Car 클래스의 세부적인 내부 로직을 속속히 너무 잘 알고 있고, 객체 간의 결합도가 높은 상태를 의미한다.
  • HOW) 캡슐화를 활용하여 객체의 자율성, 즉 하나의 객체가 해당 객체의 속성과 기능에 대한 독점적인 책임을 담당하도록 만들고, 이를 통해 객체 간의 결합도를 낮게 유지할 수 있다.
class Car(private val model: String, private val color: String) {
    private fun startEngine() {
        println("시동을 건다")
    }

    private fun moveForward() {
        println("자동차가 앞으로 전진한다")
    }

    private fun openWindow() {
        println("모든 창문을 연다")
    }
    
    fun operate() {
        startEngine()
        moveForward()
        openWindow()
    }
}

class Driver(private val name: String, private val car: Car) {
    fun drive() {
        car.operate()
    }
}

fun main() {
    val car = Car("테슬라 모델X", "레드")
    val driver = Driver("김코딩", car)
    
    driver.drive()
}

// 시동을 건다
// 자동차가 앞으로 전진한다
// 모든 창문을 연다
  • 기존의 Driver 클래스가 하나하나 호출해줬던 메서들을 모두 operate() 메서드로 묶어 Car 클래스로 옮겨두었고, Driver 클래스에서는 내부 동작을 전혀 신경쓰지 않아도 단순히 operate() 메서드를 호출하여 사용하고 있다.
  • 또한, operate() 메서드 내부의 메서드들은 외부에서 호출되어 사용할 일이 없으므로 접근 제어자를 모두 private으로 변경해주었다.
  • 정리 : Car 클래스와 관련된 기능들은 온전히 Car에서만 관리되도록 하고, 불필요한 내부 동작의 노출을 최소화하였다. 이제 Driver 클래스의 입장에서는 더 이상 Car 클래스의 내부 로직을 알지 못하고, 알 필요도 없어졌다.

참고 사이트

profile
필요한 내용을 공부하고 저장합니다.

0개의 댓글