本章探討停車場(Parking Lot)系統的物件導向設計,這是技術面試中最熱門的題目之一。停車場應用程式提供一套完整方案,自動化處理車輛進出、車位分配等流程,並能準確掌握使用率與產生停車票券。

設計這個系統的第一步是釐清需求。

Parking Lot

需求蒐集#

題目情境#

想像你開車到一個繁忙的停車場。在入口取得票券後駛入找位、停車。離開時於出口出示票券,系統計算費用,車位即釋出供下一輛車使用。背景中,停車場依車輛大小分配車位、紀錄進出時間,並更新可用狀態。請設計這樣一個停車場系統。

需求釐清對話#

Candidate:支援哪些車種?

Interviewer:三種——motorcyclescarstrucks

Candidate:有哪些停車位類型?

Interviewer:三種——compact、regular、oversized。

Candidate:系統如何決定車輛該停哪個車位?

Interviewer:依車輛大小指派合適的車位。

Candidate:是否在入場時發券、離場時計費?

Interviewer:是。入場時發券(含車輛資訊與入場時間),離場時依停留時間與車種計費,並釋出車位。

Candidate:費率如何計算?

Interviewer:依停留時間與車輛大小,並依時段而異。

需求整理#

釐清需求時務必書面記錄重點,避免模糊與矛盾。沒有什麼比解錯題更糟。

功能性需求(Functional Requirements)#

  • 停車場有多個車位,包含 compactregularoversized
  • 支援 motorcyclescarstrucks 三種車種
  • 車輛依大小被分配到對應車位
  • 入場時取得包含車輛資訊與入場時間的停車票券;離場時依停留時間、車輛大小與時段付費

非功能性需求(Non-Functional Requirements)#

  • 系統需可擴展以支援大型停車場
  • 系統必須可靠地追蹤車位指派與票券細節,確保操作正確

辨識核心物件#

開始設計前,先列出核心物件:

  • Vehicle:代表需要停車的車輛,封裝車牌與大小(small/medium/large),作為車位指派與計費的基礎
  • ParkingSpot:模擬單一停車位,是車輛實際停放的物理空間,根據容量限制可停車種
  • Ticket:車輛入場時發出的票券,記錄票券 ID、關聯車輛、指派車位與入場時間,供離場計費與釋位使用
  • ParkingManager:負責車位分配、查找與釋放,是車位管理邏輯的中心
  • ParkingLot:作為 facade,提供集中介面管理系統的關鍵功能(入場、車位指派、發券、計費),並將細節委派給 ParkingManager 與 FareCalculator

Design Choice:之所以拆成這五個物件,是為了分離關注點:Vehicle 與 ParkingSpot 定義核心物理實體、Ticket 追蹤每次停車事件、ParkingManager 處理分配、ParkingLot 作為協調 facade。

想了解更多 Facade Pattern 與其使用情境,請參考章末的**延伸閱讀**。

類別圖設計#

Vehicle#

我們將 Vehicle 設計為介面(interface),定義所有車種的標準。它定義兩個關鍵方法:

  • getLicensePlate() — 回傳車牌號碼
  • getSize() — 回傳 VehicleSize enum(SMALLMEDIUMLARGE

具體類別實作 Vehicle:

  • Motorcycle:SMALL
  • Car:MEDIUM
  • Truck:LARGE

Vehicle and its concrete classes

Design Choice:為什麼用 getSize() 而非 getType()

getType() 會把系統綁死在特定車種名稱上(Motorcycle、Car…),每新增一種車就要更動計費或分配邏輯。getSize() 將之抽象掉——停車場關心的是大小而非車種語意。新增電動滑板車?標為 small 即可,與機車一視同仁。系統因此精簡、易於擴充。

ParkingSpot#

ParkingSpot 介面代表單一車位,包含是否被佔用、大小等狀態。具體類別 CompactSpotRegularSpotOversizedSpot 各自實作。

Parking Spot and its concrete classes

Design Choice:ParkingSpot 刻意保持精簡,僅封裝自身狀態(可用性與大小)。較複雜的操作(如尋找可用車位、追蹤已停車輛)由 ParkingManager 負責。這讓新增車位類型不會帶來不必要的複雜度。

ParkingManager#

ParkingManager 負責管理車位的分配與追蹤,主要功能:

  • 識別可用車位
  • 為車輛分配最合適的車位
  • 記錄車輛與其位置

兩個核心方法:

  • parkVehicle(Vehicle vehicle) — 為車輛分配符合大小的車位
  • unparkVehicle(Vehicle vehicle) — 車輛離場時釋出車位並更新狀態

ParkingManager class diagram

Design Choice:ParkingManager 集中管理車位分配與追蹤邏輯,讓 ParkingLot 維持為輕量的 facade,僅協調高階操作(入場、發券、離場)。這種職責分離提升了模組化與可擴展性。

Ticket#

Ticket 代表車輛進入停車場時產生的票券,記錄入場/離場時間以計算停留時間,並連結車輛與車位。

Ticket class diagram

Design Choice:Ticket 設計為簡潔、不可變(immutable)的紀錄,僅封裝必要資料。複雜邏輯(如費率計算)委派給 FareCalculator,確保 Ticket 的單一職責。

FareStrategy 與 FareCalculator#

我們設計 FareStrategy 介面為費率調整建立標準,讓不同定價規則能彈性套入:

  • BaseFareStrategy:依票券時長與車輛大小計算基本費
  • PeakHoursFareStrategy:依時段加成

由於一次停車常涉及多種規則(時長、大小、時段),我們設計 FareCalculator 來協調這些變化並計算最終費用。

這套定價邏輯依賴 Strategy Pattern,讓系統能動態選擇與切換不同規則。

FareStrategy interface and FareCalculator class

Design Choice:FareStrategy 介面把定價邏輯封裝成可互換的模組,讓 ParkingLot facade 維持輕量,把費率計算委派給 FareCalculator。這個設計根植於 Strategy Pattern,兼顧彈性、可維護性與可擴充性。

ParkingLot#

ParkingLot 是系統核心,作為 facade 提供管理停車場關鍵操作的簡單介面。它透過下列流程管理車輛進出:

  • 為入場車輛產生票券
  • 透過 ParkingManager 指派車位
  • 離場時呼叫 FareCalculator 計費

ParkingLot class diagram

完整類別圖#

Class Diagram of Parking Lot

這張圖展示了一個看似複雜的系統是如何由簡單、設計良好的元件組合而成。

程式實作#

Vehicle#

定義 Vehicle 介面、VehicleSize enum 與具體類別:

public interface Vehicle {
    String getLicensePlate();

    VehicleSize getSize();
}

public class Car implements Vehicle {
    private String licensePlate;

    public Car(String licensePlate) {
        this.licensePlate = licensePlate;
    }

    @Override
    public String getLicensePlate() {
        return this.licensePlate;
    }

    @Override
    public VehicleSize getSize() {
        return VehicleSize.MEDIUM;
    }
}

public enum VehicleSize {
    SMALL,
    MEDIUM,
    LARGE
}

每個車輛物件都提供 license plate(追蹤用)與 size(管理車位用)兩個關鍵屬性。為節省篇幅,省略 Motorcycle 與 Truck 的程式碼。

Implementation ChoiceVehicleSize enum 標準化大小,確保型別安全的比較。

替代方案的取捨

  • Strings:易打錯、比較較慢(O(n))、需驗證——因脆弱與效能問題棄用
  • Integers:語意模糊、易出錯,缺乏型別安全——因可讀性差而棄用

ParkingSpot#

public interface ParkingSpot {
    boolean isAvailable();

    void occupy(Vehicle vehicle);

    void vacate();

    int getSpotNumber();

    VehicleSize getSize();
}

方法說明:

  • isAvailable() — 檢查車位是否空閒,協助 ParkingManager 決定能否指派
  • occupy(Vehicle vehicle) — 若可用則指派車輛
  • vacate() — 將車輛設為 null,車位重新可用
  • getSize() — 回傳固定的 VehicleSize,協助配對

CompactSpot 實作範例:

public class CompactSpot implements ParkingSpot {
    private int spotNumber;
    private Vehicle vehicle; // The vehicle currently occupying this spot

    public CompactSpot(int spotNumber) {
        this.spotNumber = spotNumber;
        this.vehicle = null; // No vehicle occupying initially
    }

    @Override
    public int getSpotNumber() {
        return spotNumber;
    }

    @Override
    public boolean isAvailable() {
        return vehicle == null;
    }

    @Override
    public void occupy(Vehicle vehicle) {
        if (isAvailable()) {
            this.vehicle = vehicle;
        } else {
            // Spot is already occupied.
        }
    }

    @Override
    public void vacate() {
        this.vehicle = null; // Make the spot available
    }

    @Override
    public VehicleSize getSize() {
        return VehicleSize.SMALL; // Compact spots fit small vehicles
    }
}

RegularSpot 與 OversizedSpot 結構類似,差別只在 getSize() 回傳值。

ParkingManager#

public class ParkingManager {
    private final Map<VehicleSize, List<ParkingSpot>> availableSpots;
    private final Map<Vehicle, ParkingSpot> vehicleToSpotMap;

    // Create Parking Manager based on a given map of available spots
    public ParkingManager(Map<VehicleSize, List<ParkingSpot>> availableSpots) {
        this.availableSpots = availableSpots;
        this.vehicleToSpotMap = new HashMap<>();
    }

    public ParkingSpot findSpotForVehicle(Vehicle vehicle) {
        VehicleSize vehicleSize = vehicle.getSize();

        // Start looking for the smallest spot that can fit the vehicle
        for (VehicleSize size : VehicleSize.values()) {
            if (size.ordinal() >= vehicleSize.ordinal()) {
                List<ParkingSpot> spots = availableSpots.get(size);
                for (ParkingSpot spot : spots) {
                    if (spot.isAvailable()) {
                        return spot; // Return the first available spot
                    }
                }
            }
        }
        return null; // No suitable spot found
    }

    public ParkingSpot parkVehicle(Vehicle vehicle) {
        ParkingSpot spot = findSpotForVehicle(vehicle);
        if (spot != null) {
            spot.occupy(vehicle); // Record the parking spot for the vehicle
            vehicleToSpotMap.put(vehicle, spot); // Remove the spot from the available list
            availableSpots.get(spot.getSize()).remove(spot);
            return spot; // Parking successful
        }
        return null; // No spot found for this vehicle
    }

    public void unparkVehicle(Vehicle vehicle) {
        ParkingSpot spot = vehicleToSpotMap.remove(vehicle);
        if (spot != null) {
            spot.vacate();
            availableSpots.get(spot.getSize()).add(spot);
        }
    }
}

關鍵方法說明:

  • findSpotForVehicle:依車輛大小,從最小可容納的車位開始尋找
  • parkVehicle:呼叫 findSpotForVehicle 取得車位、佔用、記錄並從可用清單移除
  • unparkVehicle:取得車輛對應車位、釋出並重新加入可用清單

Implementation Choice:使用兩個 HashMap:

  • availableSpots:以 VehicleSize 為 key 維護可用車位清單。確保車輛能停在最合適尺寸的車位
  • vehicleToSpotMap:紀錄每台車輛佔用的車位,方便離場時快速釋位

使用 HashMap 讓存取為 O(1),並以 VehicleSize 組織車位以實現「最佳合適(best fit)」分配。

Ticket#

public class Ticket {
    private final String ticketId; // Unique ticket identifier
    private final Vehicle vehicle; // The vehicle associated with the ticket
    // The parking spot where the vehicle is parked     private final ParkingSpot parkingSpot;
    // // The time the vehicle entered the parking lot
    private final LocalDateTime entryTime; // The time the vehicle exited the parking lot
    private LocalDateTime exitTime;

    public Ticket(
            String ticketId, Vehicle vehicle, ParkingSpot parkingSpot, LocalDateTime entryTime) {
        this.ticketId = ticketId;
        this.vehicle = vehicle;
        this.parkingSpot = parkingSpot;
        this.entryTime = entryTime;
        // Initially, exitTime is null because the vehicle is still parked         this.exitTime =
        // null;
    }

    public BigDecimal calculateParkingDuration() {
        return new BigDecimal(
                Duration.between(
                                entryTime,
                                Objects.requireNonNullElseGet(exitTime, LocalDateTime::now))
                        .toMinutes());
    } // getter and setter methods are omitted for brevity
}

FareStrategy 與 FareCalculator#

public interface FareStrategy {
    BigDecimal calculateFare(Ticket ticket, BigDecimal inputFare);
}

Implementation Choice:FareStrategy 設為介面,支援彈性、可擴充的定價規則,新增策略(如 WeekendDiscountStrategy)不需修改既有程式碼。

BaseFareStrategy 實作:

public class BaseFareStrategy implements FareStrategy {
    private static final BigDecimal SMALL_VEHICLE_RATE = new BigDecimal("1.0");
    private static final BigDecimal MEDIUM_VEHICLE_RATE = new BigDecimal("2.0");
    private static final BigDecimal LARGE_VEHICLE_RATE = new BigDecimal("3.0");

    // Calculate fare based on the duration and add it to the input fare to return a new total
    @Override
    public BigDecimal calculateFare(Ticket ticket, BigDecimal inputFare) {
        BigDecimal fare = inputFare;
        BigDecimal rate;
        switch (ticket.getVehicle().getSize()) {
            case MEDIUM:
                rate = MEDIUM_VEHICLE_RATE;
                break;
            case LARGE:
                rate = LARGE_VEHICLE_RATE;
                break;
            default:
                rate = SMALL_VEHICLE_RATE;
        }
        fare = fare.add(rate.multiply(ticket.calculateParkingDuration()));
        return fare;
    }
}

PeakHoursFareStrategy 實作:

public class PeakHoursFareStrategy implements FareStrategy {
    // 50% higher during peak hours     private static final BigDecimal PEAK_HOURS_MULTIPLIER = new
    // BigDecimal("1.5");

    public PeakHoursFareStrategy() {}

    @Override
    public BigDecimal calculateFare(Ticket ticket, BigDecimal inputFare) {
        BigDecimal fare = inputFare;
        if (isPeakHours(ticket.getEntryTime())) {
            fare = fare.multiply(PEAK_HOURS_MULTIPLIER);
        }
        return fare;
    }

    private boolean isPeakHours(LocalDateTime time) {
        int hour = time.getHour();
        return (hour >= 7 && hour <= 10) || (hour >= 16 && hour <= 19);
    }
}

FareCalculator:

public class FareCalculator {
    private final List<FareStrategy> fareStrategies;

    public FareCalculator(List<FareStrategy> fareStrategies) {
        this.fareStrategies = fareStrategies;
    }

    public BigDecimal calculateFare(Ticket ticket) {
        BigDecimal fare = BigDecimal.ZERO;
        for (FareStrategy strategy : fareStrategies) {
            fare = strategy.calculateFare(ticket, fare);
        }
        return fare;
    }
}

Implementation Choice:使用 List<FareStrategy> 而非 array 或 Set 是因為順序很重要。BaseFareStrategy 必須在 PeakHoursFareStrategy 之前套用,否則計算結果會錯。Set 會丟失順序,array 則固定大小、缺乏彈性。

ParkingLot 實作#

public class ParkingLot {
    // Manages parking spots and vehicle assignments     private final ParkingManager
    // parkingManager;
    // Calculates fare for parking sessions     private final FareCalculator fareCalculator;

    public ParkingLot(ParkingManager parkingManager, FareCalculator fareCalculator) {
        this.parkingManager = parkingManager;
        this.fareCalculator = fareCalculator;
    }

    // Method to handle vehicle entry into the parking lot
    public Ticket enterVehicle(Vehicle vehicle) {
        // Delegate parking logic to ParkingManager
        ParkingSpot spot = parkingManager.parkVehicle(vehicle);

        if (spot != null) {
            // Create ticket with entry time
            Ticket ticket = new Ticket(generateTicketId(), vehicle, spot, LocalDateTime.now());
            return ticket;
        } else {
            return null; // No spot available
        }
    }

    // Method to handle vehicle exit from the parking lot
    public void leaveVehicle(Ticket ticket) {
        // Ensure the ticket is valid and the vehicle hasn't already left
        if (ticket != null && ticket.getExitTime() == null) {
            // Set exit time
            ticket.setExitTime(LocalDateTime.now());

            // Delegate unparking logic to ParkingManager
            parkingManager.unparkVehicle(ticket.getVehicle());

            // Calculate the fare
            BigDecimal fare = fareCalculator.calculateFare(ticket);
        } else {
            // Invalid ticket or vehicle already exited.
        }
    }
}
  • enterVehicle(Vehicle) — 透過 ParkingManager 取得車位、產生 Ticket
  • leaveVehicle(Ticket) — 設定離場時間、釋出車位、由 FareCalculator 算費

深度討論#

新增車位類型#

停車場已支援三種車位,但可能需要新增身障專用車位(HandicappedSpot)。挑戰在於遵循開放/封閉原則——擴充而不修改既有程式碼。

我們新增 HandicappedSpot 類別實作既有 ParkingSpot 介面,即可無縫整合進 ParkingManager 的分配邏輯。

ParkingSpot with HandicappedSpot class

public class HandicappedSpot implements ParkingSpot {
    private int spotNumber;
    private Vehicle vehicle;

    public HandicappedSpot(int spotNumber) {
        this.spotNumber = spotNumber;
        this.vehicle = null;
    }

    @Override
    public int getSpotNumber() {
        return spotNumber;
    }

    @Override
    public boolean isAvailable() {
        return vehicle == null;
    }

    @Override
    public void occupy(Vehicle vehicle) {
        if (isAvailable()) {
            this.vehicle = vehicle;
        } else {
            // Spot is already occupied.
        }
    }

    @Override
    public void vacate() {
        this.vehicle = null;
    }

    @Override
    public VehicleSize getSize() {
        return VehicleSize.MEDIUM;
    }
}

加速車位管理:雙向對應#

目前的對應是單向的(Vehicle → ParkingSpot),可快速找出某車輛停在哪。但若要反查「某車位停了哪輛車」,就得遍歷所有車位,效率低落。

解法:新增反向對應#

引入 spotToVehicleMap 維護 ParkingSpot → Vehicle 的反向對應:

  • vehicleToSpotMap:追蹤每輛車的車位
  • spotToVehicleMap:追蹤每個車位上的車
public class ParkingManager {
    private final Map<VehicleSize, List<ParkingSpot>> availableSpots;
    private final Map<Vehicle, ParkingSpot> vehicleToSpotMap;
    private final Map<ParkingSpot, Vehicle> spotToVehicleMap;

    // Create Parking Manager based on a given map of available spots
    public ParkingManager(Map<VehicleSize, List<ParkingSpot>> availableSpots) {
        this.availableSpots = availableSpots;
        this.vehicleToSpotMap = new HashMap<>();
        this.spotToVehicleMap = new HashMap<>();
    }

    public ParkingSpot findSpotForVehicle(Vehicle vehicle) {
        // No change in the method
    }

    public ParkingSpot parkVehicle(Vehicle vehicle) {
        ParkingSpot spot = findSpotForVehicle(vehicle);
        if (spot != null) {
            spot.occupy(vehicle);
            // Record bidirectional mapping
            vehicleToSpotMap.put(vehicle, spot);
            spotToVehicleMap.put(spot, vehicle);
            // Remove the spot from the available list
            availableSpots.get(spot.getSize()).remove(spot);
            return spot; // Parking successful
        }
        return null; // No spot found for this vehicle
    }

    public void unparkVehicle(Vehicle vehicle) {
        ParkingSpot spot = vehicleToSpotMap.remove(vehicle);
        if (spot != null) {
            spotToVehicleMap.remove(spot);
            spot.vacate();
            availableSpots.get(spot.getSize()).add(spot);
        }
    }

    // Find vehicle's parking spot
    public ParkingSpot findVehicleBySpot(Vehicle vehicle) {
        return vehicleToSpotMap.get(vehicle);
    }

    // Find which vehicle is parked in a spot
    public Vehicle findSpotByVehicle(ParkingSpot spot) {
        return spotToVehicleMap.get(spot);
    }
}

雙向對應讓兩種查詢都能達到 O(1),在大型停車場特別有價值,避免了昂貴的線性搜尋。

本章小結#

本章透過詳細的問答蒐集需求、辨識核心物件、設計類別結構,並實作系統關鍵元件。

主要收穫是模組化清晰的職責分離。每個元件——Vehicle、ParkingSpot、ParkingManager、FareCalculator——各司其職,使系統易維護、易擴充。

設計選擇如以 ParkingLot 作為 facade、使用 FareStrategy 介面提供彈性定價,皆強調簡潔與適應性。替代方案(如把車位分配與費率邏輯直接塞入 ParkingLot)雖能減少類別數量,卻會讓單一類別承擔過多職責,影響可擴展性。在面試中能反思並闡述這些決策,正是展現你 OOD 取捨能力的好時機。

延伸閱讀:Strategy 與 Facade Design Patterns#

Strategy Design Pattern#

Strategy 是一種行為型設計模式,定義一系列演算法、將每個演算法封裝在獨立類別中、讓它們可以互換。

在停車場設計中,我們以 FareStrategy 介面封裝定價規則,讓 FareCalculator 能在不更動核心邏輯的前提下動態切換規則。

問題情境:電商支付系統#

開發一個電商應用程式,提供信用卡、PayPal、銀行轉帳等付款方式。若直接把每種支付方式塞進結帳流程,會導致支付處理邏輯與結帳系統緊密耦合,難以新增或修改支付方式。

解法#

Strategy 模式建議將每個支付演算法封裝為獨立類別(strategy)並使其可互換。主應用程式(context)持有 strategy 物件的參考,將支付處理委派給該物件。

Strategy design pattern class diagram

何時使用#

  • 應用程式需在執行期依條件選擇不同演算法
  • 類別中堆滿了選擇演算法變體的條件式
  • 需要將業務邏輯與任務實作細節分離

Facade Design Pattern#

Facade 是一種結構型設計模式,為複雜子系統提供簡化介面,藏起底層複雜度。

在停車場設計中,ParkingLot 即扮演 facade 角色,協調入場、車位分配、計費等操作,將細節委派給 ParkingManager 與 FareCalculator。

問題情境:家庭劇院#

設定一個家庭劇院系統,包含 DVD player、投影機、音響、燈光等多個元件。要看電影必須一一開機、調整設定、彼此同步——使用者必須了解每個元件運作。

解法#

Facade 模式引入一個facade介面封裝子系統複雜度。例如 HomeTheaterFacade 提供 watchMovie() 方法,內部負責開投影機、設定音響等。客戶端只與 facade 互動。

Facade design pattern class diagram

何時使用#

  • 子系統複雜,需要為客戶端提供更簡單的介面
  • 想將系統分層為子系統,但仍提供統一入口供常見操作