Skip to content

[Daily Questions Challenge 32]
OOP 與 FP:物件導向與函數式程式設計的深入對比

[Daily Questions Challenge 32] OOP 與 FP:物件導向與函數式程式設計的深入對比

程式設計有兩種主流典範:OOP(物件導向程式設計)FP(函數式程式設計)

OOP 的核心是「物件」——把資料與行為包在一起,透過物件之間的互動完成工作。FP 的核心是「函數」——把程式視為一系列資料轉換,避免可變狀態與副作用。

聽起來都很合理,但這兩種思維在實作上會帶來截然不同的設計選擇:狀態怎麼管、副作用放在哪、程式碼怎麼組合、測試難不難寫——每一個問題,兩種典範都有不同的答案。

這篇文章會逐一拆解這些差異,並以 Python 程式碼說明。

OOP 核心概念

物件導向程式設計(Object-Oriented Programming, OOP)的核心思想是:把資料與行為綁在一起,形成物件。程式由互相溝通的物件組成,每個物件負責管理自己的狀態,並透過方法對外暴露行為。

OOP 的四個基本柱子:

  • 封裝(Encapsulation):把資料(屬性)與操作資料的方法包在物件內,隱藏內部細節,只暴露必要的介面。
  • 繼承(Inheritance):子類別可以繼承父類別的屬性與方法,達成程式碼重用與擴充。
  • 多型(Polymorphism):不同類別的物件可以用統一的介面呼叫,各自執行自己的實作。
  • 抽象(Abstraction):只暴露操作的「是什麼」,隱藏「怎麼做」的細節。

Python OOP 範例:

python
from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, name: str):
        self.name = name  # 封裝:name 是物件內部狀態

    @abstractmethod
    def speak(self) -> str:  # 抽象:定義介面,子類別決定實作
        pass

class Dog(Animal):
    def speak(self) -> str:  # 多型:同介面,不同行為
        return f"{self.name} 說:汪!"

class Cat(Animal):
    def speak(self) -> str:
        return f"{self.name} 說:喵~"

animals: list[Animal] = [Dog("小黑"), Cat("咪咪")]
for a in animals:
    print(a.speak())
# 小黑 說:汪!
# 咪咪 說:喵~

FP 核心概念

函數式程式設計(Functional Programming, FP)的核心思想是:用函數的組合描述計算過程,避免可變狀態與副作用。程式是由一系列資料轉換組成,而不是物件之間的互動。

FP 的四個核心概念:

  • 純函數(Pure Function):相同輸入永遠產生相同輸出,且不改變外部狀態。純函數沒有副作用。
  • 不可變性(Immutability):資料建立後不應被修改,轉換資料時產生新的資料,而非修改原有資料。
  • 高階函數(Higher-Order Function):函數可以作為參數傳入另一個函數,或作為回傳值,讓程式碼更靈活組合。
  • 函數組合(Function Composition):將多個小函數串接,讓資料依序流過每個函數,完成複雜的轉換。

Python FP 範例:

python
from functools import reduce

# 純函數:相同輸入,永遠相同輸出,無副作用
def add(a: float, b: float) -> float:
    return a + b

# 高階函數:接受函數作為參數
def apply(fn, values: list[float]) -> list[float]:
    return [fn(v) for v in values]

prices = [100.0, 200.0, 300.0]
discounted = apply(lambda p: p * 0.9, prices)  # [90.0, 180.0, 270.0]

# 函數組合:filter → map → reduce 串接
total = reduce(
    add,
    map(lambda p: p * 0.9, filter(lambda p: p > 100, prices))
)
# filter: [200.0, 300.0]
# map:    [180.0, 270.0]
# reduce: 450.0

深入對比

1. 狀態管理:可變狀態 vs 不可變資料

這是 OOP 與 FP 最根本的哲學差異。

OOP 版本:購物車是一個物件,狀態隨時間改變。

python
# OOP:可變狀態
class Cart:
    def __init__(self):
        self.items: list[dict] = []

    def add_item(self, name: str, price: float) -> None:
        self.items.append({"name": name, "price": price})  # 直接修改內部狀態

    def total(self) -> float:
        return sum(item["price"] for item in self.items)

cart = Cart()
cart.add_item("Python 書籍", 600)
cart.add_item("鍵盤", 1200)
print(cart.total())  # 1800.0
# cart.items 已被修改,無法追溯加入商品前的狀態

FP 版本:資料是不可變的 tuple,每次「新增」都回傳一個新的資料結構。

python
# FP:不可變資料
from typing import NamedTuple

class Item(NamedTuple):
    name: str
    price: float

def add_item(cart: tuple[Item, ...], item: Item) -> tuple[Item, ...]:
    return cart + (item,)  # 不修改原本的 cart,回傳新的 tuple

def total(cart: tuple[Item, ...]) -> float:
    return sum(item.price for item in cart)

cart: tuple[Item, ...] = ()
cart_with_book = add_item(cart, Item("Python 書籍", 600))
cart_with_both = add_item(cart_with_book, Item("鍵盤", 1200))
print(total(cart_with_both))  # 1800.0
# 原始的 cart 從未被修改,任何中間狀態都可保留

OOP 版本的 cart.items 在呼叫 add_item 後就被永久改變;FP 版本每次都產生一個新的 tuple,可以保留任何中間狀態,更容易追溯與除錯。

2. 程式碼組織:類別與繼承 vs 函數組合

OOP 版本:透過繼承擴充行為,形成固定的類別階層。

python
# OOP:繼承擴充
class Order:
    def __init__(self, amount: float):
        self.amount = amount

    def final_price(self) -> float:
        return self.amount

class DiscountedOrder(Order):
    def __init__(self, amount: float, discount_rate: float):
        super().__init__(amount)
        self.discount_rate = discount_rate

    def final_price(self) -> float:
        return self.amount * (1 - self.discount_rate)

class TaxedOrder(Order):
    def __init__(self, amount: float, tax_rate: float):
        super().__init__(amount)
        self.tax_rate = tax_rate

    def final_price(self) -> float:
        return self.amount * (1 + self.tax_rate)

print(DiscountedOrder(1000, 0.1).final_price())  # 900.0
print(TaxedOrder(1000, 0.05).final_price())      # 1050.0
# 若需要「先折扣再加稅」,就必須再定義一個 DiscountedAndTaxedOrder

FP 版本:透過函數組合,將折扣與稅金各自做成純函數,再自由組合。

python
# FP:函數組合
from functools import reduce
from typing import Callable

def apply_discount(rate: float) -> Callable[[float], float]:
    return lambda amount: amount * (1 - rate)

def apply_tax(rate: float) -> Callable[[float], float]:
    return lambda amount: amount * (1 + rate)

def compose(*fns: Callable[[float], float]) -> Callable[[float], float]:
    return lambda x: reduce(lambda v, f: f(v), fns, x)

# 自由組合:先打折,再加稅,不需要新的類別
discounted_then_taxed = compose(apply_discount(0.1), apply_tax(0.05))
print(discounted_then_taxed(1000))  # 1000 * 0.9 * 1.05 = 945.0

# 只打折,不加稅
just_discounted = compose(apply_discount(0.2))
print(just_discounted(1000))  # 800.0

OOP 的繼承會形成固定的類別階層,增加新的「組合規則」需要新增類別;FP 的函數組合像樂高積木,可以任意拆裝,彈性更高。

3. 副作用處理

副作用(Side Effect) 是指函數修改了自身範圍以外的狀態——例如修改全域變數、寫入資料庫、列印到 console,或修改傳入的物件。

OOP 版本:方法通常直接修改物件狀態,業務邏輯與副作用混在一起。

python
# OOP:副作用與業務邏輯耦合
class ReportService:
    def __init__(self):
        self.log: list[str] = []

    def calculate_and_log(self, orders: list[float], discount: float) -> float:
        total = sum(o * (1 - discount) for o in orders)
        self.log.append(f"計算完成,金額:{total}")  # 副作用:修改 self.log
        print(f"[LOG] 計算完成,金額:{total}")       # 副作用:印出到 console
        return total

service = ReportService()
result = service.calculate_and_log([100.0, 200.0, 300.0], 0.1)
# 「計算」與「記錄」綁在同一個方法,無法單獨測試純計算邏輯

FP 版本:純計算與副作用明確分層,副作用集中在程式最外層邊界。

python
# FP:純計算與副作用分離
def calculate_total(orders: tuple[float, ...], discount: float) -> float:
    return sum(o * (1 - discount) for o in orders)  # 純函數,無副作用

def build_log_message(total: float) -> str:
    return f"計算完成,金額:{total}"  # 純函數,只產生資料,不輸出

# 副作用集中在最外層處理
orders = (100.0, 200.0, 300.0)
total = calculate_total(orders, 0.1)  # 純計算
message = build_log_message(total)    # 純計算
print(message)                         # 副作用:只在邊界發生

FP 的做法讓「計算」部分完全可測試,「副作用」部分集中到程式的最外層,大幅降低意外改到狀態的風險。

4. 測試難度

副作用的差異直接影響測試的難易度。

OOP 測試:需要先建立物件、設置狀態,測試後還要確認副作用是否正確發生。

python
import unittest

class TestReportService(unittest.TestCase):
    def test_calculate_and_log(self):
        service = ReportService()          # 需要建立物件
        result = service.calculate_and_log([100.0, 200.0], 0.1)
        self.assertEqual(result, 270.0)
        self.assertEqual(len(service.log), 1)  # 還需要驗證副作用是否發生
        # 若 calculate_and_log 內有 print,測試輸出也會被污染

FP 測試:純函數只看輸入與輸出,不需要任何前置設置。

python
import unittest

class TestCalculations(unittest.TestCase):
    def test_calculate_total(self):
        result = calculate_total((100.0, 200.0), 0.1)
        self.assertEqual(result, 270.0)
        # 不需要 mock、不需要物件狀態,直接給輸入、驗輸出

    def test_build_log_message(self):
        msg = build_log_message(270.0)
        self.assertEqual(msg, "計算完成,金額:270.0")

純函數天然適合參數化測試(Parameterized Test):輸入固定,輸出必然固定,可以用一個測試函數驗證大量輸入輸出組合,而不需要針對每種狀態各建一個物件。

各自適用場景

OOP 適合:

  • 領域建模(Domain Modeling):系統中有明確的「實體」概念,例如 UserOrderProduct,每個實體有自己的狀態與行為。
  • GUI / 元件系統:如 UI 框架中的元件(Widget、View),天然具有狀態(顯示、隱藏、選中)與行為(點擊、滑動)。
  • 長生命週期物件:需要管理複雜狀態的物件,例如資料庫連線池、Session 管理、遊戲角色。

FP 適合:

  • 資料轉換 Pipeline:ETL 流程、日誌解析、報表生成等需要對資料做一系列轉換的場景。
  • 並行計算(Concurrent / Parallel Computing):不可變性讓多個執行緒可以安全地同時讀取資料,不需要加鎖。
  • 業務規則引擎:定價規則、折扣計算、資格判斷,純函數讓規則獨立可測試,規則本身也容易組合。

典範專責語言

有些語言在設計之初就選定了一種典範,並以此為核心。

以 FP 為主的語言

  • Haskell:最純粹的 FP 語言。預設不可變,副作用必須透過 IO Monad 明確標記,根本不存在傳統的可變狀態。
  • Erlang / Elixir:為高並行系統設計,資料完全不可變,沒有 class 概念,程式由獨立的輕量級 Process 組成,透過訊息傳遞溝通。
  • Clojure:執行在 JVM 上的 Lisp 方言,強調不可變資料結構,狀態變更有明確的並行控制機制。

以 OOP 為主的語言

  • Smalltalk:OOP 的起源。「所有東西都是物件」,連整數、布林值也是物件,方法呼叫稱為「傳訊息(Message Passing)」。
  • Ruby:設計上一切皆物件,語言核心緊扣 OOP,FP 風格相對薄弱。
  • Java(Java 8 以前):幾乎強制用 class 組織程式,沒有 first-class function;直到 Java 8 才加入 lambda 與 Stream API。

現代主流語言(Python、Kotlin、Scala、TypeScript、C#)幾乎都走向多典範,同時支援 OOP 與 FP,讓開發者按場景選擇。本文所有範例皆以 Python 示範,正是因為它能自然地呈現兩種風格。

總結

面向OOPFP
核心抽象物件(狀態 + 行為)函數(輸入 → 輸出)
狀態管理可變狀態,物件擁有自己的狀態不可變資料,轉換產生新資料
副作用方法可以改變物件或外部狀態明確隔離副作用,純函數無副作用
程式組織繼承與類別階層函數組合
測試難度需要設置物件狀態,副作用要 mock純函數直接給輸入驗輸出,無需 mock
適合場景領域建模、GUI、長生命週期物件資料轉換、並行計算、業務規則引擎

面試中如果被問到這道題,建議的回答結構是:

  1. 說明兩者的核心抽象不同:OOP 以物件封裝狀態與行為,FP 以純函數轉換資料。
  2. 點出狀態管理與副作用的哲學差異:OOP 接受可變狀態,FP 明確隔離副作用。
  3. 舉一個具體的使用場景說明各自優勢。
  4. 補充現代語言通常兩者兼支援,Python 就是一個好例子,根據場景選擇風格才是重點。

參考