物件導向(OOP) 在 Java, Python, JS, C++中的詮釋

Page Contents

在學習每個語言時,都一會學到每個語言的 “物件”,但隨著學習的語言越來越多,我也漸漸的開始混淆了。

這篇文章就來深度探討一下物件導向(OOP) 在 Java, Python, JS, C++中的詮釋


要了解物件導向我們需要先來釐清“物件”一詞,在各個語言中的代表意義。

What is OBJECT ?

總結: object = interface/attributes + method/function

Java:Class-based Object

物件是類別 (Class)實例 (Instance)

用class宣告物件,物件是 類別 (Class)實例 (Instance),透過new關鍵字建立物件。

class Person {
  String name;
  void sayHello() {
    System.out.println("Hi");
  }
}
Person p = new Person(); // p 是物件

Python:Everything is an Object

萬物皆物件。

如果你去把python中的東西都用type()印出來會發現他們都是class。即使是簡單的 5 (int) 或 "hello" (string),它們都有自己的類型(類別)和方法。

x = 10
print(type(x))  # <class 'int'>

def foo():
    pass

print(type(foo))  # <class 'function'>

但是class還是存在,只是變成宣告物件的一種方式,讓你可以在執行期動態加屬性的物件。

p.age = 20

JavaScript:Prototype-based Object-Oriented

物件是屬性 (Properties)動態集合,每個屬性都將一個Key映射到一個值Value。

物件是JS語言的核心,繼承靠 prototype chain

const person = {
  name: "Tom",
  sayHello() {
    console.log("Hi");
  }
};
Prototype 機制

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function () {
  console.log("Hi");
};

const p = new Person("Tom");

C++:Object = memory layout

物件是 Class結構 (Struct)實例 (Instance),廣泛的定義為: 在執行程式期間,擁有記憶體區塊,並且可以儲存數值的區域。

若以記憶體區間來定義,即使是基本型別的變數(例如 int x;),在技術上也稱為一個物件,因為它在記憶體中有儲存空間。但當我們以 C++ OOP角度來定義時,通常指的是由 class 定義所建立的、包含資料成員和成員函數的實例。

class Person {
public:
    string name;
    void sayHello() {
        cout << "Hi";
    }
};

Person p;        // stack object
Person* ptr = new Person();  // heap object

C++中有兩種建立物件的方式,但這兩種又有些微差異:

Stack Object

Person p;
  • 定義與行為:
    • 儲存位置: 物件 p 儲存在程式的堆疊 (Stack) 記憶體中。
    • 生命週期: 遵循區塊作用域 (Block Scope)。它在程式執行進入其定義的區塊時自動建立,並在執行離開該區塊時結束。
    • 記憶體管理: 完全由編譯器和運行時環境自動管理,無需手動釋放記憶體。
    • 優點: 速度快、效率高、沒有記憶體洩漏的風險。
    • 存取方式: 直接使用物件名稱 (p.namep.sayHello())。

Heap Object

Person* ptr = new Person();
  • 定義與行為:
    • 儲存位置: 物件實體儲存在程式的堆積 (Heap) 記憶體中。而儲存該物件位址 (Address) 的指標變數 ptr 則通常位於堆疊(stack)上。
    • 生命週期: 物件本身不依賴於作用域。它在執行 new 運算子時被建立,並會一直存在,直到程式員手動呼叫 delete 運算子為止。
    • 記憶體管理: 必須手動管理。如果忘記呼叫 delete ptr;,就會發生記憶體洩漏 (Memory Leak)
    • 優點: 可以創建大型物件,或在函數返回後仍需要繼續存在的物件。
    • 存取方式: 透過指標使用箭頭運算子 (->):(ptr->nameptr->sayHello())。

了解了物件的定義後我們來看看又分別如何在這些語言中實踐物件導向。


Object-Oriented Programming

物件導向不外乎就是圍繞著這四個核心的開發哲學:

  • 封裝(Encapsulation)
  • 繼承(Inheritance)
  • 多型(Polymorphism)
  • 抽象(Abstraction)

這四件事在「概念上相同」,但在不同語言中實作方式與限制完全不同


Java:教科書級的物件導向

Encapsulation – Modifier, 透過修飾子來完成封裝

class User {
    private String name;

    public String getName() {
        return name;
    }
}
ModifierVisibility/Access Scope
private類別內
default同 package
protected繼承 + package
public全世界

Inheritanceis-a。表是: …是一種… 的概念。

Car 繼承自 Vehicle,表示「汽車是一種交通工具」。

class Animal {
    void speak() {}
}

class Dog extends Animal {
    @Override
    void speak() {
        System.out.println("Woof");
    }
}
  • 單一繼承,Java 不允許子類別的多重繼承(class X extends A, B),避免「菱形問題」(Diamond Problem)。
  • final 可阻止子類別的繼承
  • method override(類別定義一個與父類別中同名、同參數列表、同返回型別的方法時)有嚴格檢查。

PolymorphismExistence & Legality,編譯器只確保方法存在合法性。它並不知道運行時具體會呼叫哪個版本的method。

我們從Inheritance 那欄的程式碼繼續往下看:

Animal a = new Dog();
a.speak();  // 輸出: Woof

上面說到了,編譯器只確保方法存在合法性。它並不知道運行時具體會呼叫哪個版本的method,所以到底要呼叫誰的?答案就是看物件實體 (Actual Object) 的型別,即 new Dog()

  • 發生的事情:
    • 在程式運行時,JVM (Java Virtual Machine) 會透過變數 a 所指向的實際物件來確定要執行的方法。
    • 雖然 a 的引用型別是 Animal,但它指向的堆積 (Heap) 記憶體中存儲的是一個 Dog 實體
    • 由於 Dog 覆寫 (Override) 了 Animalspeak() 方法,JVM 將遵循虛擬方法呼叫 (Virtual Method Invocation) 機制,執行 Dog 類別中的 speak() 方法。
  • 結論: 實際執行的程式碼版本是在運行時確定的,這就是動態繫結 (Dynamic Binding)。因此,輸出是 "Woof"

Abstraction – Abstract Class & Interface,將系統分為「如何使用」和「如何實現」兩層,大大提高了程式碼的可維護性和可擴展性。

抽象是 OOP中最為高階且重要的概念之一,核心在於「關注點分離」和「定義契約」。

  • 契約 (Contract): 抽象類別和介面都是一種契約。任何繼承(或實現)它們的具體類別,都必須遵守並實現這些契約中定義的方法。
/**
 * 抽象類別:Shape (形狀)
 * 目的:定義所有幾何形狀的通用結構和契約。
 * 備註:使用 'abstract' 關鍵字修飾,不能直接實例化。
 */
abstract class Shape {

    protected double length; 
    protected double width; 
    protected double height;

    /**
     * 建構函式 (Constructor)
     * 用於初始化所有 Shape 子類別都需要的通用屬性。
     */
    public Shape(double length, double width, double height) {
        this.length = length;
        this.width = width;
        this.height = height;
    }

    // 抽象方法 (Abstract Method) - 必須由子類別客製化實現的契約

    /**
     * 抽象方法:area() - 計算面積
     * 只有宣告,沒有實作內容 (方法體)。
     * 契約:任何繼承自 Shape 的子類別 (如 Rectangle, Circle) 都『必須』提供自己的 area() 具體計算邏輯。
     * @return 該形狀的面積 (double)
     */
    public abstract double area();

    // 具體方法 (Concrete Method) - 共用的方法實現

    /**
     * 具體方法:getDimensions() - 取得尺寸資訊
     * 實作內容已在抽象類別中定義,所有子類別可以直接繼承和使用,無需重複編寫。
     * @return 形狀的尺寸描述字串
     */
    public String getDimensions() {
        return "尺寸: 長=" + length + ", 寬=" + width + ", 高=" + height;
    }

    /**
     * 具體方法:displayInfo() - 顯示完整的形狀資訊
     * 結合了具體方法和抽象方法的結果,展示抽象類別的協同作用。
     */
    public void displayInfo() {
        System.out.println("--- 形狀資訊 ---");
        System.out.println(getDimensions());
        // 呼叫抽象方法 (在子類別中實現的邏輯)
        System.out.println("計算面積: " + area()); 
        System.out.println("-----------------");
    }
}

透過繼承來實現Shape 抽象類別。

class Rectangle extends Shape {

    // 長方形只需要長和寬,高設為 0
    public Rectangle(double length, double width) {
        // 呼叫父類 (Shape) 的建構函式來初始化通用屬性
        super(length, width, 0); 
    }

    // 實現抽象方法 (Fulfilling the Contract)

    /**
     * 實作 Shape 類別的抽象方法 area()。
     * 這是必須的,否則 Rectangle 類別本身也必須宣告為 abstract。
     * @Override 註解用於確保正確覆寫。
     */
    @Override
    public double area() {
        // 提供長方形特有的面積計算邏輯
        return length * width;
    }
}

實際運行看看

// 測試程式
public class AbstractionTest {
    public static void main(String[] args) {
        // Shape 抽象類別不能被實例化:Shape s = new Shape(10, 5, 0); // 錯誤!

        // 實例化具體子類別
        Rectangle rect = new Rectangle(10.0, 5.0);

        // 透過多型引用 (Polymorphic Reference) 存取
        Shape shapeRef = rect; 

        // 呼叫具體方法 (從 Shape 繼承)
        shapeRef.displayInfo();
        
        // 預期輸出:
        // --- 形狀資訊 ---
        // 尺寸: 長=10.0, 寬=5.0, 高=0.0
        // 計算面積: 50.0 
        // -----------------
    }
}

Python:物件導向只是「工具」

Encapsulation – Conventional,不透過修飾子來硬性規定類別中的成員可見性,而是透過軟性封裝,以”約定”來告知其他開發者。

class User:
    def __init__(self):
        self._age = 18      # 保護(約定)
        self.__id = 123    # 名稱改寫(name mangling)
  • 單底線 _ 開頭的member,表示它是受保護的 (Protected)
    • 沒有任何存取限制。可以從類別外部直接存取和修改 obj._age
    • 告訴其他程式設計師:「這個成員是內部使用的,請不要直接存取或修改它。」 呼叫者應該尊重這個約定,並透過屬性或方法(Getter/Setter)來間接操作它。
  • 雙底線 __ 開頭的member會觸發 Python 直譯器的名稱改寫 (Name Mangling) 機制。
    • 在運行時,__id 會被自動改寫成一個包含類別名稱的唯一名稱,格式為 ClassName__member
      • User 類別內,__id 的實際名稱會變成:_User__id
    • 如果從類別外部使用 obj.__id 存取,會得到 AttributeError,因為這個名稱已經不存在了。(但既然知道了他的改寫規則,其實還是可以繞過機制去存取)
    • 雖然它提供了比單底線更強的保護,但它並非真正的 private,而是一種名稱混淆 (Name Obfuscation) 機制,主要是為了防止屬性名稱在繼承時與子類別的名稱發生衝突 (Collision)

Property

在 Python 中,實現封裝的最佳方式是使用 @property 裝飾器:外部程式碼就可以像存取普通屬性一樣存取 u.age,但實際上卻經過了 Setter 方法的控制和驗證,完美地實現了封裝的目標。

在對使用 @property 定義的屬性進行『賦值』時,會進入 @property.setter 方法進行驗證;而在『讀取』該屬性時,會進入 @property 方法(Getter)獲取值。

class User:
    def __init__(self, age):
        # 實際儲存數據的內部變數 (約定不直接存取)
        self._age = age 

    @property # 1. 定義 Getter (讀取器)
    def age(self):
        print("正在讀取 age...")
        return self._age

    @age.setter # 2. 定義 Setter (設定器)
    def age(self, value):
        print(f"正在設定 age 為 {value}...")
        if 0 < value <= 150: # <--- 在這裡進行驗證!
            self._age = value
        else:
            raise ValueError("年齡必須在 1 到 150 之間")

# --- 外部呼叫 ---
u = User()

# 外部程式碼看起來像在存取屬性,但實際上呼叫了 Getter
print(u.age) 
# 輸出: 正在讀取 age... / 10

# 外部程式碼看起來像在賦值,但實際上呼叫了 Setter
try:
    u.age = 160 
except ValueError as e:
    print(f"錯誤: {e}") 
# 輸出: 正在設定 age 為 160... / 錯誤: 年齡必須在 1 到 150 之間

Inheritance – Multiple & C3 Linearization,允許多重繼承,並透過C3 Linearization算法解決菱形問題。

class Flyable:
    def fly(self): pass

class Bat(Animal, Flyable): # Bat 同時繼承自 Animal 和 Flyable
    pass

當一個類別從多個父類別繼承了同名的方法時(例如,AB 都有 do_something() 方法,而 C 繼承了 AB),Python 必須決定執行哪一個版本。

Python 使用一種稱為 C3 線性化算法 (C3 Linearization) 來確定 方法解析順序 (Method Resolution Order, MRO)。

  • MRO 概念: MRO 定義了 Python 搜尋方法或屬性的順序。當呼叫一個方法時,Python 會依據這個順序,從當前類別開始,向上追溯到所有父類別,直到找到該方法為止。
  • 如何查看 MRO: 可以使用類別的 .__mro__ 屬性或 mro() 方法來查看這個順序。
# 範例:
class Base: pass
class A(Base): pass
class B(Base): pass
class C(A, B): pass

print(C.mro())
# 輸出: [<class 'C'>, <class 'A'>, <class 'B'>, <class 'Base'>, <class 'object'>]

Polymorphism – Duck Typing,物件是什麼型別重要,而是它能做什麼行為

鴨子型別的核心思想是:「如果它走起來像鴨子,叫起來像鴨子,那麼它就是一隻鴨子。」(If it walks like a duck and quacks like a duck, it’s a duck.)

def make_sound(animal):
    # Python 在編譯期不知道 animal 是什麼型別
    # 它只知道在運行時,它需要 animal 有一個 speak() 方法
    animal.speak()
  • Java/C++ (靜態多型): 必須透過繼承(Inheritance)或介面(Interface)來確保物件具有通用的父類或介面型別,才能實現多型。
  • Python (動態多型): 不要求繼承關係,只要求物件在運行時具備所需的方法。
    • 沒有編譯期檢查介面: Python 沒有編譯期,所以不會有人檢查Existence & Legality
    • 方法就是介面: 方法本身(即 speak() 這個名稱)就是契約。只要物件有這個「行為」,它就遵守了契約。
    • 錯誤延遲: 潛在的型別錯誤(例如傳入的物件沒有 speak 方法)不會在程式開始前被發現,而是會延遲到運行時實際執行到 animal.speak() 這行程式碼時才會爆發。

Abstraction – abc model,動態語言導致上述的錯誤延遲,這時可以使用套件增強錯誤檢查。

from abc import ABC, abstractmethod

class Shape(ABC):    
    @abstractmethod
    def area(self):
        pass

實現方式: 由於 Python 預設是 Duck Typing,為了強制實現像 Java 那樣的「契約」,Python 提供了標準函式庫 abc (Abstract Base Classes) 模組。

  • ABC 任何繼承自 ABC 的類別都被視為抽象基類。
  • @abstractmethod 標記在方法上,宣告這個方法必須由子類別實現。
  • 如果一個類別繼承了 Shape(ABC) 並且帶有 @abstractmethod 的方法,當嘗試實例化 (Instantiate) 這個子類別時,如果沒有實現所有抽象方法,Python 會立即拋出 TypeError

Python 不強制抽象!

  • Python 可以在不使用 abc 模組的情況下編寫程式碼,依靠 Duck Typing 達成多型。abc 的使用是自願的,是為了提供更清晰的架構和更強的錯誤檢查。
  • Java 如果宣告一個 abstract classinterface,編譯器會強制檢查其所有具體子類別是否實現了所有抽象方法。這種約束是編譯時 (Compile-Time) 的,無從規避。

JavaScript:物件導向 ≠ class

「物件直接存在,class 是後來加的」

在靜態語言中(java,C++)我們要宣告物件都要透過類別(Class),但在JS中,物件是不需用透過class的(不過JS有提供class語法糖)。

const user = {
  name: "Tom",
  getName() {
    return this.name;
  }
};

JS 原生是 Prototype-based,物件是鍵值對 (Key-Value Pairs) 的動態集合,將數據 (name) 和操作數據的行為 (getName()) 捆綁在一起。

所有的存取全都是public的,直到ES11才引入了private的寫法:

class User {
  #id = 123; // 私有屬性

  constructor(name) {
    this.name = name;
  }

  getId() {
    return this.#id; // 只能在類別內部存取
  }
}

const user2 = new User("Jane");
// user2.#id; // 錯誤,無法在外部存取

Inheritance – Prototype Chain,實例 ->子類原型 -> 父類原型 -> Object 原型。

在 JavaScript 中,繼承並非像傳統語言那樣複製程式碼,而是建立一條鏈接,當呼叫一個方法時,JS 引擎會沿著這條鏈逐層查找,直到找到第一個匹配的方法為止。

function Animal() { // 建構函式 (Constructor Function) & 建立類別(Class)
    this.name = "Instance Name";
}

Animal.prototype.speak = function () {
    console.log(`${this.name} says Woof!`); 
};

const dog1 = new Animal(); 
dog1.name = "Snoopy"; // 設定實例名稱

const dog2 = new Animal();
dog2.name = "Pluto";

dog1.speak(); // 輸出: Snoopy says Woof!
dog2.speak(); // 輸出: Pluto says Woof!

原型物件 (Prototype Object) 指的就是Animal這種具有”雙重身分”的function。

  • 這個原型物件是所有 未來將由 Animal 創建出來的實例 所共享的「倉庫」。JS會把共用的方法(行為)放在這裡,以節省記憶體。

如果用下面這種寫法,當創建一個新物件時(new Animal()) ,每個物件都會有自己的speak,如果創建 100 個實例,就會有 100 個功能完全相同的 speak 函數實體在記憶體中。NO!

function Animal() {
    this.name = "Instance Name"; // 實例屬性

    // 寫法二:方法定義在建構函式內
    this.speak = function () {
        console.log("Woof");
    };
}

  • JS 的多形與python一樣使用鴨子型別 (Duck Typing)
function makeSound(obj) {
  obj.speak();
}
Dog.prototype.speak = function() { console.log("Woof!"); }	// 輸出 "Woof!"
Cat.prototype.speak = function() { console.log("Meow!"); }	// 輸出 "Meow!"
const robot = { speak: () => console.log("Beep Boop.") };

JS沒有抽象,但TS有 – interface。

因為TS本來就是用來在編譯前就先檢查錯誤的JS進化版,所以也會有嚴格檢查執行的抽象介面,也就是前端開發最常用的Inteerface。

前端開發中,type與interface也會優先選擇interface,因為宣告合併 (Declaration Merging)這項特性在擴展第三方庫的型別或在模組化專案中非常有用。它提供了一個清晰且非破壞性的方式來增加現有型別的功能,而不需要去修改原始定義。

interface Options { id: number; }

interface Options { verbose: boolean; } // TS 自動合併

// 最終 Options: { id: number; verbose: boolean; }

C++ : 物件就是你定義的記憶體區塊

C++的物件導向與同為靜態語言的JAVA高度相似,只不過C++的設計哲學更接近讓開發者操控一切,更注重效能和底層控制。

  • 可以創建一個物件的實體(Instance)在Stack上,這個實體是值(Value)。如:MyClass obj;
  • 也可以創建一個物件的指標Heap上。如:MyClass* ptr = new MyClass();
  • C++ 提供了對物件生命週期的完全控制。

JAVA的設計哲學讓開發者只能在Heap上操作,並透過引用(Reference)來存取物件。

  • 物件本身一定在Heap上分配。
  • 變數儲存的是對這個物件的引用。例如:MyClass obj = new MyClass(); 這裡 obj 是一個引用。
  • Java 自動管理記憶體,開發者無需擔心釋放。

Inheritance and Polymorphism – Runtime Polymorphism,運行時多形與Dynamic Binding

class Animal {
public:
    virtual void speak(); // virtual 決定是否動態繫結(Dynamic Binding)
};

class Dog : public Animal {
public:
    void speak() override; // 強制編譯器檢查子類別的方法是否確實覆寫了父類別的虛擬方法
};

當父類別的方法被標記為 virtual 時,編譯器就會為該類別的物件創建一個虛擬函式表 (vtable)。系統會在運行時查詢 vtable,根據實際指向的物件類型(例如 Dog),來決定執行哪一個版本的方法,稱作動態繫結 (Dynamic Binding)


Abstraction – Pure Virtual Function,純虛擬函式,不能被實作new Shape())。

基於它不能被實作,它的唯一目的是作為介面或契約: 任何繼承自 Shape 的子類別(除非它自己也是抽象類別)必須實現(覆寫)area() 方法,否則編譯器會報錯,從而強制了抽象契約。

class Shape {
public:
    virtual double area() = 0; // 純虛擬函式
};

= 0 這是 C++ 中定義純虛擬函式 (Pure Virtual Function) 的語法,一個包含純虛擬函式的類別被稱為抽象類別 (Abstract Class)


Object Lifetime – Steak & Heap

Dog d;             // stack (棧)
Dog* p = new Dog(); // heap (堆)
delete p;

棧 (Stack) 上的物件 (Dog d;):

  • 優點: 記憶體分配和釋放極快。
  • 生命週期: 嚴格遵循作用域規則(Scope),當程式碼執行離開 d 定義的區塊時,d自動銷毀(呼叫解構函式 ~Dog()),記憶體自動釋放。

堆 (Heap) 上的物件 (Dog* p = new Dog();):

  • 優點: 記憶體可以在程式的任何地方存取,生命週期可以由開發者控制。
  • 風險: 必須手動使用 delete p; 進行釋放,否則會導致記憶體洩漏 (Memory Leak)

心得

天啊,我也沒想到我能寫完這麼長一大篇,雖然有AI協作,但我也將AI給的生硬的字詞,轉化為我自認為算口語的說法了(反正我自己是看得懂)

會想要寫這篇小報告(小論文??)的起因是我在自學後端的時候,學到springBoot的設計哲學,整個框架的設計哲學時,讀到了物件導向這件事,我就突然腦中冒出一個聲音問我自己: 你知道要如何定義你會的四個語言中的”物件”是什麼嗎?

我才突然察覺,對ㄟ,我學了這麼多語言,每個語言的教科書都有提到物件這件事,但是老實講我完全不知道這些語言中的物件分別是什麼意思,更何況物件導向是什麼意思。

所以就這樣,我馬上去問GPT : 如何定義Java, python, js, c++中的“物件”,再繼續了解到整個物件導向。

說真的,整個編寫過程下來,真的學到很多,光是JAVA、C++的設計哲學與Python的運作原理就學到很多了,完全沒想到竟然也能學到前端interface與type兩種宣告data的方式到底差異在哪裡(就是差異在interface才是物件導向的多形最佳實作方式,所以用interface)。

現在AI真的太發達了,我也是寫完才發現,對ㄟ這樣也算是一種現代人專屬的學習方式,真的很慶幸自己出生在這個時代,10幾年前,如果要一次搞懂這四個語言的”物件導向”差異,可能真的要寫一篇論文,查閱超多資料才能完成了,但是現在,我只用了兩天,兩個AI,就能完成10幾年前看似超困難的學習!!!

而且寫完滿滿一篇(雖然程式碼大部分是AI寫的)成就感真的很高,透過理解AI的程式碼來學習,不懂就問他,畢竟是他生成的程式碼。這種學習方式真的很有效率。

接下來,就來用這種學習方式逐漸去精進前端和後端,甚至資料庫與系統網路規劃等專業了!!(但李董建議我可以朝AI方向,不過那感覺有點太難,我學多一點東西再踏進AI好了)

真的是學習使我快樂餒ww,我有信心接下來有人問我物件導向的問題,我應該都能回答得出來了(應該)!!!

Leave a Reply

Your email address will not be published. Required fields are marked *