Programming Language/Theory

[PLT/프로그래밍언어론] 10. Object Oriented Paradigm

lumana 2024. 12. 22. 01:05

 

10. OOP()

#PLT


객체 지향 패러다임(Object-Oriented Paradigm)

프로그래밍 언어 이론(Programming Language Theory)


객체 지향 프로그래밍(OOP)과 데이터 추상화(Data Abstraction)


데이터 추상화(Data Abstraction)

  • 물리적 기계는 bit strings이라는 한 가지 타입만 처리합니다.
  • High-level Language에서는 다양한 값을 “wrapping하여 제공합니다.
    • 타입(Type): 값 + 연산
  • Data Abstraction: 값들이 하나로 결합되어 새로운, 더 추상적인 타입을 형성하고 적절한 연산을 가질 수 있습니다.
  • 이러한 새로운 타입을 통해 실제 세계의 복잡한 객체를 표현할 수 있습니다.

장점

  • 복잡한 데이터 구조를 더 쉽게 처리할 수 있습니다.
  • 세부 사항을 신경 쓸 필요가 없습니다.
    • InterfaceImplementation의 분리
    • 구현을 알지 못해도 인터페이스를 통해 사용할 수 있습니다.
  • Information Hiding: 데이터는 정의된 인터페이스를 통해서만 접근할 수 있습니다.

추상 데이터 타입(Abstract Data Type, ADT)


  • ADT의 간단한 형태를 생각해 봅시다.
    • type part와 operation part가 있습니다.
  • 명세(specification)는 ADT 연산의 의미를 설명합니다.
    • 예: push()는 스택의 맨 위에 요소를 삽입합니다.

abstract class Stack {
    struct Stack {
        int info;
        Stack next;
    }
    // 연산들
    Stack create();
    Stack push(Stack s, int x);
    Stack pop(Stack s);
    int top(Stack s);
}

클래스로 ADT를 표현하긴 했지만, ADT는 클래스랑은 상관이 없다.


  • 명세를 올바르게 구현하고 인터페이스를 유지하는 한 ADT 사용에 차이가 없습니다.
  • definitionuse을 분리합니다.
  • 정보(데이터 및 구현)가 숨겨지고 보호됩니다.

표현 독립성(Representation Independence)


  • ADT의 단일 명세에 대한 두 개의 올바른 구현은 이 타입을 사용하는 클라이언트에게 동일하게 보입니다.
  • type-safe language에서는
    • 시그니처(인터페이스)가 같은 다른 ADT로 대체해도 타입 오류가 발생하지 않습니다.
    • 필요충분조건이다~

ADT의 한계

  • 간단한 Counter 예시를 살펴봅시다.
    • 타입 부분은 단순히 카운터가 정수임을 나타냅니다.
    • 연산 부분은 카운터의 몇 가지 주요 기능을 구현합니다.

abstract class Counter {
    int c;
    void reset();
    int get();
    void increment();
}

  • 조금 더 고급화된 카운터가 필요하다고 가정해봅시다.
    • 이제 리셋 횟수를 기록하기 위한 추가 필드를 ADT에 추가할 수 있습니다.
    • 이제 reset()은 Counter와 다릅니다.

예시:

abstract Counter2 { 
  type Counter2 =  
  struct { 
    int x; 
    int num_reset; 
  } 
  operations: 
    void reset(Counter2 c) {  
      c.x = 0; 
      c.num_reset += 1; 
    } 
    int get(Counter2 c) { 
      return c; 
    } 
    void increment(Counter2 c) { 
      c = c + 1; 
    } 
    int get_resets(Counter2 c) { 
      return c.num_reset; 
    } 
}

  • 문제점CounterCounter2 사이에 아무런 관계가 없다는 것입니다.
    • Counter 명세에 변경이 발생하면, 비록 동일한 연산을 포함하고 있다 하더라도, 모든 ADT에 대해 변경을 해야 합니다.
    • 예를 들어, get()increment() 같은 연산들이 그렇습니다.

1을 늘려주려면 Counter를 쓰고, 2를 늘려주려면 Counter2를 쓰고, ….100을 늘려주려면 Counter100을 써야 되는 상황


Counter에서 상속하기 (Let's Inherit from Counter)


  • Counter의 기존 연산을 재사용하여 Counter3를 만들 수 있습니다.
    • CounterCounter3 모두를 변경해야 하는 경우, 이제는 Counter에서만 수정하면 됩니다.
  • Counter에서 사용할 수 있는 모든 연산이 Counter3에서도 사용할 수 있습니다.
    • Counter3Counter호환됩니다.

예시:

abstract Counter3 { 
  type Counter3 =  
  struct { 
    Counter x; 
    int num_reset; 
  } 
  operations: 
    void reset(Counter3 c) {  
      reset(c.x); 
      c.num_reset += 1; 
    } 
    int get(Counter3 c) { 
      return get(c.x); 
    } 
    void increment(Counter3 c) { 
      increment(c.x); 
    } 
    int get_resets(Counter3 c) { 
      return c.num_reset; 
    } 
}

Counter가 바뀌더라도, Counter3는 다른 걸 손대지 않아도 된다. 단순히 Counter를 호출만 하기 때문.


문제 해결? (Problem Solved?)


  • Counter 또는 Counter3 객체가 100개 있는 경우를 가정합시다.
    • 모든 객체를 reset하고자 할 때, reset(arr[i])를 호출하면 어떻게 될까요?
    • 이 연산은 매개변수로 Counter 또는 Counter3를 받지만, reset(Counter) 연산만 실행됩니다.
    • 적합한 메서드를 동적으로 선택할 수 없습니다.

Counter arr[100]; 
... 
arr[9] = new Counter(); 
arr[10] = new Counter3(); 
... 

for(int i=0; i<100; i++){ 
  reset(arr[i]); 
}

void reset(Counter c) {  
  c = 0; 
}

void reset(Counter3 c) {  
  reset(c.x); 
  c.num_reset += 1; 
}

arr의 타입이 Counter이기 때문에 reset(arr[i])를 하면 reset(Counter c)가 호출된다.


ADT의 한계 (Limits of ADT)


  • ADT캡슐화정보 은닉에는 적합하지만, 더 복잡한 설계에는 유연성이 부족합니다.
  • 이를 해결하기 위해 객체 지향 패러다임이 요구하는 네 가지 조건이 필요합니다:
    • 캡슐화정보 은닉
    • 상속 (Inheritance)
    • 서브타입 호환성 (Subtype Compatibility)
    • 동적 메서드 선택 (Dynamic Method Selection)

OOP 핵심 개념 (OOP Key Concepts)


객체 (Object)


  • 객체데이터와 연산을 캡슐화한 컨테이너입니다.
  • ADT의 변수는 그저 데이터만을 나타냅니다.
    • ADT에서는 연산을 호출할 때마다 변수를 매개변수로 전달해야 했습니다.
      • 예시: void increment(Counter c)
  • 객체 내의 데이터 항목을 인스턴스 변수라 하고, 연산을 메서드라 합니다.

메서드 호출 (Method Invocation)


  • 객체의 연산을 수행하려면 해당 객체에 메시지를 보내야 합니다.
    • 형식: object.method(params)
    • (제어권이 메서드한테 있음)
  • 이는 객체에 메서드를 호출하도록 지시하는 것입니다.
  • 객체 자체가 메서드의 implicit parameter로 간주될 수 있습니다.

클래스 (Class)


  • 클래스는 객체 집합을 모델링한 것입니다.
  • 클래스가 있는 언어에서는 모든 객체가 클래스에 속해 있습니다.
  • 객체는 클래스의 인스턴스로 볼 수 있습니다.
    • 또는 객체는 해당 클래스의 instantiation에 의해 동적으로 생성될 수 있다.
      • 예시: Counter c = new Counter();
      • c가 Counter의 object를 가리킨다.
      • 보통 constructor를 호출해서 instantiation한다. (클래스로 부터 객체를 만드는 과정을 instantiation이라고 한다)

클래스와 객체 (Class and Object)


reset, get, inc는 컴파일 될 때 딱 한 번 저장된다.
만약 static으로 선언되었다면 모든 메서드 정보가 컴파일러에게 전달된다.


인스턴스에서는 static 메서드 정의에 대한 포인터를 가지고 있다.
인스턴스 자체는 동적으로 생성된다.
static 함수에서는 동적으로 생성되는 것들을 사용할 수 없다.


  • 메서드는 클래스에 단 한 번만 저장됩니다.
  • 각 객체(인스턴스)는 각자의 인스턴스 변수를 가집니다.
  • 클래스의 메서드에 대한 포인터가 객체에도 저장됩니다.
  • 메서드가 자기 자신을 참조하기 위해 this(또는 self)를 사용합니다.

캡슐화 (Encapsulation)


  • 보통 두 가지 뷰가 존재합니다: public과 private.
  • 숨겨야 하는 정보는 private에 배치됩니다.
    • 일반적으로 데이터와 일부 메서드는 private으로, 인터페이스 역할을 하는 메서드는 public으로 설정됩니다.
  • Encapsulation는 데이터를 연산과 함께 클래스라는 캡슐로 감싸는 것입니다.
  • Information hiding은 이 캡슐 내부의 세부 사항을 숨기는 것으로, 이를 private area에 배치하여 이루어집니다.

서브타입 (Subtypes)


  • 서브타입이란 TS의 서브타입일 때, S의 인터페이스가 T의 인터페이스의 부분집합임을 의미합니다.
  • S에서 사용 가능한 모든 연산이 T에서도 사용 가능해야 합니다.
  • TS에 있는 필드와 더불어 추가 필드를 가질 수 있습니다.

예시:

class NewCounter extends Counter { 
  private int num_reset = 0; 
  public int get_reset() { 
    return this.num_reset; 
  } 
}

생성자 (Constructors)


  • 객체를 생성하려면 두 가지 작업이 필요합니다:
    • 메모리 할당 (Allocation of Memory)
    • 객체의 데이터를 초기화하는 초기화 작업 (Initialization)
    • 후자는 생성자에 의해 수행됩니다.
  • 일반적으로 클래스 이름과 동일한 이름을 사용하거나, 다르게 명확하게 지정된 이름을 사용합니다.

예시:

class Counter { 
  public Counter() { 
    ... 
  } 
}

class Counter: 
  def __init__(self): 
    ...

(언어별 예시: C++, Java는 클래스 이름을 생성자로 사용하고, Python__init__ 메서드를 생성자로 사용합니다.)



생성자 선택 (Constructor Selection)


  • 클래스에 여러 생성자가 있을 수 있습니다. 이는 오버로딩(overloading)으로 불립니다.
  • 어떤 생성자를 사용할지 결정해야 합니다.
    • 예를 들어 C++에서는 복사 생성자(Copy Constructor)가 존재합니다.
  • 생성자 체이닝 (Constructor Chaining)
    • 상위 클래스가 초기화되는 시점과 방식에 대한 문제
      • 언어의 디자인에 따라 다름
      • 자바에서는 제일 먼저 상위 클래스의 생성자를 호출한다.
    • 어떤 상위 클래스 생성자를 어떤 인자로 호출할지 결정하는 문제
      • 어떤 건 파라미터 2개짜리, 어떤건 3개짜리…

상속 (Inheritance)


  • 상속이란 기존 객체(또는 클래스)를 재사용하여 새로운 객체(또는 클래스)를 정의하는 것을 말합니다.
  • InheritanceSubtype
    • Inheritance은 possibility of code reuse(Implementation Inheritance)과 관련이 있습니다.
      • 부모 클래스의 상속을 받은 자식에서 부모 클래스의 메서드를 사용할 수 있다.
    • Subtype은 compatibility(Interface Inheritance)과 관련이 있습니다.
  • protected 키워드를 통해 하위 클래스가 상위 클래스의 멤버에 접근할 수 있도록 합니다.
  • 단일 상속(Single Inheritance)과 다중 상속(Multiple Inheritance)
    • 하나의 상위 클래스만 상속하는 경우 또는 여러 상위 클래스를 상속하는 경우
    • 단일 상속 자바
    • 다중 상속 C++
  • 다중 상속에서는 문제점이 발생할 수 있습니다.
    • Name Conflict
    • 다이아몬드 문제(Diamond Problem)


다이아몬드 문제 (Diamond Problem)


C++의 경우 어떤 클래스에 속하는 함수(ex. B::f())인지 밝혀줘야 한다. 안 그러면 에러가 발생한다.

  • 다중 상속 시, 어떤 f() 메서드를 호출할지 모호해지는 문제가 발생합니다.
    • 명시적으로 qualified name을 사용해 호출할 메서드를 지정해야 합니다.

동적 메서드 조회 (Dynamic Method Lookup)


class Counter { 
  private int x = 0; 
  public void reset() { 
    x = 0; 
  } 
  public void inc() { 
    x += 1; 
  } 
  public int get() { 
    return x; 
  } 
}

class NewCounter extends Counter { 
  private int num_reset = 0; 
  public void reset() { 
    x = 0; 
    num_reset += 1; 
  } 
  public int get_reset() { 
    return this.num_reset; 
  } 
}


Counter arr[100]; 
... 
arr[9] = new Counter(); 
arr[10] = new NewCounter(); 
... 

for(int i=0; i<100; i++){ 
  arr[i].reset(); 
}

이전 예시에서는 reset(arr[i]) 이런식으로 전달해줬기 때문에 무조건 Counter의 reset으로 전달되었지만,
지금은 객체의 메서드를 호출함으로써 동적으로 해당 타입에 대한 메서드를 호출해줄 수 있다.


  • 이전 카운터 예시를 다시 살펴봅시다.
    • CounterNewCounter가 클래스에서 정의된 경우
    • 카운터들을 reset하고자 할 때 무슨 일이 발생할까요?

class B extends A { 
  int b = 0; 
  void g() { b = 2; } 
}

class A { 
  int a = 0; 
  void f() { g(); } 
  void g() { a = 1; } 
}

B b = new B(); 
b.f();

  • 컴파일러는 어떤 객체가 해당 메서드를 호출할지 결정할 수 없습니다.
  • 예를 들어, 클래스 A에서 f 메서드가 g 메서드를 호출할 때, this.g()로 호출하게 됩니다.
    • this가 참조하는 객체에 따라 A의 g가 호출될지, B의 g가 호출될지 결정된다.
    • 실행 중에 this가 어떤 객체를 가리키는지에 따라 실제로 호출되는 g 메서드가 결정됩니다.
    • 이를 late binding 또는 Dynamic binding이라 부릅니다.

메서드 오버라이딩과 쉐도잉 (Method Overriding and Shadowing)


  • 메서드 오버라이딩(Method Overriding): 서브타입에서 메서드를 재정의할 수 있습니다.
  • 쉐도잉(Shadowing): 서브타입에서 필드도 재정의할 수 있습니다.
    • 다만 메서드 오버라이딩필드 쉐도잉에는 차이가 있습니다.
    • 오버라이딩은 동적(dynamic)으로 이루어지지만, 쉐도잉은 정적(static)입니다.
public class A {
	public int num = 5;
	public int update() {
		return num-1;
	}
}
public class B extends A {
	public int num = 3;
	public int update() {
		return num+5;
	}
}
B b= new B();
A a = b;
System.out.println(b.num); //3
System.out.println(b.update()); //8
System.out.println(a.num); //5
System.out.println(a.update()); //8

 

 

 

 

 

 

OOP 구현 (OOP Implementations)

객체: 단순 구현 (Objects: Simple Implementation)

class B extends A { 
  int b; 
  void f() { ... } // 재정의됨 
  void h() { ... } 
}

class A { 
  int a; 
  void f() { ... } 
  void g() { ... } 
}

B obj = new B(); 

obj.a; 
obj.f(); 
obj.g();

 

부모클래스들과 본인 클래스의 멤버 변수를 해당 객체가 가지고 있고, 해당 클래스의 메서드 정보가 담겨 있는 곳의 포인터가 존재한다.
obj가 포인터를 따라 B로 간다 -> f가 있으므로 f를 호출하고, g가 없네? -> 부모 클래스로 계속 가면서 g가 있으면 호출한다.

 

 

-> 상속을 4~5번 한 클래스의 객체에서 최상위 부모의 메서드를 실행한다면? -> 너무 오래걸린다. (linked list 조회의 O(n)을 생각해보자)

 

단일 상속 (Single Inheritance)

링크드 리스트의 조회시간을 줄이자 -> 해시 테이블(virtual function table). 
필드와 메서드는 컴파일 타임에 결정된다. -> virtual 테이블을 만들어둘 수 있다. 이 virtual table에 클래스의 메서드를 다 놔둔다.

 

class A { 
  int a; 
  char c; 
  void f() { ... } 
  void g() { ... } 
}

class B extends A { 
  int a; 
  int b; 
  void f() { ... } // 재정의됨 
  void h() { ... } 
}


B pb = new B(); 
A pa = new A(); 
A aa = pb;

 

비록 vtable은 컴파일은 정적인 시간에 생성되지만, 어떤 메서드가 호출될 지는 동적으로 결정된다.

 

다운캐스팅(Downcasting)

다운캐스팅은 상위 클래스에서 하위 클래스로의 캐스팅을 허용합니다.

    Object clone() { ... } // 객체 복사 생성
    A o = ...; 
    A a = o.clone();   // Object != A
    A a = (A) o.clone();   // OK


다운 캐스팅은 implicit하게 할 수 없다. explicit하게 해줘야 한다.(explicit conversion)

 

깨지기 쉬운 기본 클래스 문제 (Fragile Base Class Issue)

  • 객체 지향 시스템에서 많은 클래스가 존재하며, 일부 클래스는 여러 하위 클래스에 의해 상속되는 일반 클래스입니다.
    •  예: Java의 Object 클래스.
  • 클래스 계층 구조의 상위에 있는 클래스를 수정할 경우, 그 아래 모든 하위 클래스에 큰 영향을 미칩니다.
    • 자식에서 부모 필드를 모두 가지고 있는데, 부모가 바뀌면 영향이 크다.
class A { 
  int a; 
  void f() { ... } 
}

class B extends A { 
  int y; 
  void g() { return y+f(); } 
}

// class A가 수정됨
// 메서드 h 추가
class A { 
  int x; 
  void h() { return x; } 
  int f() { ... } 
}

 

컴파일 할 때 vtable이 fixed되는데, h가 추가되는 바람에 A로 부터 상속받은 모든 클래스가 recompile되야 한다.

  • 단일 상속 구현에서는 상위 클래스 A가 변경될 경우, f() 메서드를 호출하는 오프셋이 바뀌어 클래스 B에 문제가 발생합니다.
  • 이를 해결하려면 가상 함수 테이블메서드 오프셋을 동적으로 계산해야 합니다.

n: 메서드가 몇 번째 인지. 여기서는 n = 2라 n - 1 = 1

 

R2: derefence(역참조)를 위해 계산되며, R2의 값이 0이 된다. 
R2가 0이되므로 this = R1 + R2 = R1이 된다. 
이제 B에서 y의 값을 사용하기 위해선 this 포인터에서 얼만큼 떨어져있는지를 이용해서 사용할 수 있따.

B는 C이전에 선언된 부분이므로, B의 메서드에서는 z 값을 사용할 수 없다. 단순히 y 필드 하나만 있으므로 y값에 못쓴다. y 값을 사용하기 위해서는 B의 offset을 이용하면 된다. y는 B의 첫 번째 멤버변수 임을 알기 때문이다.

 

설명:

  • 다중 상속에서는 하나 이상의 상위 클래스를 상속할 수 있습니다.
  • 각 상위 클래스의 가상 함수 테이블을 참조하여 적절한 메서드를 찾습니다.
  • 다중 상속 시 이름 충돌 다이아몬드 문제가 발생할 수 있습니다.

 

다이아몬드 문제 해결하기 (Dealing With Diamond Problem)

  • 다중 상속의 경우, 상위 클래스에 공통 상위 클래스가 있을 때 다이아몬드 문제가 발생합니다.
    • 예: A와 B가 TOP을 상속하고, Bottom이 A와 B를 상속하는 경우
  • 이 경우 클래스 구성 방법에는 두 가지 방식이 있습니다:
    • Replicated Inheritance: Bottom이 Top의 복제본을 가집니다.
    • Sharing: Bottom이 Top의 한 가지 복사본만 가지고 공유해서 쓴다.

복제 다중 상속 (Replicated Multiple Inheritance)

Bottom이 Top의 복제본을 가지는 방식이다. 이 예제에서는 Top의 복제본이 두 개가 있다. (B에는 재정의된 f가 있다. 그리고 B 아래에 w가 한번 더 나타난다.)

  • Bottom 클래스는 Top의 복제본을 여러 개 가집니다.
  • Top의 f() 메서드는 Bottom에서 호출할 수 없으며, 두 개의 w 필드가 존재하므로 Bottom에서 이를 수정할 수 없습니다.
    • b.w = ?? -> 어떤 w 필드에 값을 적어야 할지 알 수 없다.
    • b.f() -> Top의 f일까? B의 f일까?
    • c++의 경우 다중 상속을 허용하는데, 이런 문제를 해결하기 위해 virtual keyword를 써서, 특정 함수가 무조건 자식 클래스에서 재정의되도록 한다. 그렇다면 B의 f가 호출되게 된다.

공유 다중 상속 (Multiple Inheritance w/ Sharing)

아까처럼 Top이 두 번 등장하는게 아니라, 한 번만 등장한다.

 

{Top, A, Bottom / Top, B}가 아니라, {A, Bottom / B / Top}이다. 
A, Bottom 다음에 Top을 가리키는 포인터를 두고, B 다음에 Top을 가리키는 포인터를 둬서 공유해서 쓴다.
Bottom까지 모두 넣은 다음, 그 아래에다가 Top table과 변수를 놔둔다. Top table의 f함수는 redefine되어 필드의 값이 -(e-d)만큼 조정된다 => 여기로 왔으면 (-e-d)로 가서 호출하라는 말

 

⠀즉, vtable 엔트리 재정의로 인해 Top의 vtable 슬롯에 실제로는 B의 f 함수 포인터가 들어가 있으므로, Top의 vtable을 거쳐도 B의 f가 호출되는 것입니다.


이렇게 하면 어떤 장점이 있냐? Top table의 오프셋 필드 값이 0이면 top의 view가 되고, -k면 -k만큼 앞에 있는 곳의 view가 된다. 위 예시에서는 (-e-d)에는 B 테이블이 있으므로 B  테이블의 view라는 걸 알 수 있다. 포인터 보정이 이루어진다.

 

설명

  • 공유 상속에서는 Top 클래스의 하나의 복사본만 사용됩니다.
  • Bottom 클래스가 Top의 f() 메서드를 호출할 때, Bottom에서 Top의 데이터를 공유합니다.
  • Top 클래스의 메서드 호출 시, 포인터 보정이 이루어져 올바른 가상 함수 테이블을 참조합니다.

 

공유 다중 상속에서의 f() 호출 (Calling f() of Bottom)