本章探討停車場(Parking Lot)系統的物件導向設計,這是技術面試中最熱門的題目之一。停車場應用程式提供一套完整方案,自動化處理車輛進出、車位分配等流程,並能準確掌握使用率與產生停車票券。
設計這個系統的第一步是釐清需求。
需求蒐集#
題目情境#
想像你開車到一個繁忙的停車場。在入口取得票券後駛入找位、停車。離開時於出口出示票券,系統計算費用,車位即釋出供下一輛車使用。背景中,停車場依車輛大小分配車位、紀錄進出時間,並更新可用狀態。請設計這樣一個停車場系統。
需求釐清對話#
Candidate:支援哪些車種?
Interviewer:三種——motorcycles、cars、trucks。
Candidate:有哪些停車位類型?
Interviewer:三種——compact、regular、oversized。
Candidate:系統如何決定車輛該停哪個車位?
Interviewer:依車輛大小指派合適的車位。
Candidate:是否在入場時發券、離場時計費?
Interviewer:是。入場時發券(含車輛資訊與入場時間),離場時依停留時間與車種計費,並釋出車位。
Candidate:費率如何計算?
Interviewer:依停留時間與車輛大小,並依時段而異。
需求整理#
釐清需求時務必書面記錄重點,避免模糊與矛盾。沒有什麼比解錯題更糟。
功能性需求(Functional Requirements)#
- 停車場有多個車位,包含 compact、regular、oversized
- 支援 motorcycles、cars、trucks 三種車種
- 車輛依大小被分配到對應車位
- 入場時取得包含車輛資訊與入場時間的停車票券;離場時依停留時間、車輛大小與時段付費
非功能性需求(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()— 回傳VehicleSizeenum(SMALL、MEDIUM、LARGE)
具體類別實作 Vehicle:
- Motorcycle:SMALL
- Car:MEDIUM
- Truck:LARGE
Design Choice:為什麼用
getSize()而非getType()?用
getType()會把系統綁死在特定車種名稱上(Motorcycle、Car…),每新增一種車就要更動計費或分配邏輯。getSize()將之抽象掉——停車場關心的是大小而非車種語意。新增電動滑板車?標為 small 即可,與機車一視同仁。系統因此精簡、易於擴充。
ParkingSpot#
ParkingSpot 介面代表單一車位,包含是否被佔用、大小等狀態。具體類別 CompactSpot、RegularSpot、OversizedSpot 各自實作。
Design Choice:ParkingSpot 刻意保持精簡,僅封裝自身狀態(可用性與大小)。較複雜的操作(如尋找可用車位、追蹤已停車輛)由 ParkingManager 負責。這讓新增車位類型不會帶來不必要的複雜度。
ParkingManager#
ParkingManager 負責管理車位的分配與追蹤,主要功能:
- 識別可用車位
- 為車輛分配最合適的車位
- 記錄車輛與其位置
兩個核心方法:
parkVehicle(Vehicle vehicle)— 為車輛分配符合大小的車位unparkVehicle(Vehicle vehicle)— 車輛離場時釋出車位並更新狀態
Design Choice:ParkingManager 集中管理車位分配與追蹤邏輯,讓 ParkingLot 維持為輕量的 facade,僅協調高階操作(入場、發券、離場)。這種職責分離提升了模組化與可擴展性。
Ticket#
Ticket 代表車輛進入停車場時產生的票券,記錄入場/離場時間以計算停留時間,並連結車輛與車位。
Design Choice:Ticket 設計為簡潔、不可變(immutable)的紀錄,僅封裝必要資料。複雜邏輯(如費率計算)委派給 FareCalculator,確保 Ticket 的單一職責。
FareStrategy 與 FareCalculator#
我們設計 FareStrategy 介面為費率調整建立標準,讓不同定價規則能彈性套入:
- BaseFareStrategy:依票券時長與車輛大小計算基本費
- PeakHoursFareStrategy:依時段加成
由於一次停車常涉及多種規則(時長、大小、時段),我們設計 FareCalculator 來協調這些變化並計算最終費用。
這套定價邏輯依賴 Strategy Pattern,讓系統能動態選擇與切換不同規則。
Design Choice:FareStrategy 介面把定價邏輯封裝成可互換的模組,讓 ParkingLot facade 維持輕量,把費率計算委派給 FareCalculator。這個設計根植於 Strategy Pattern,兼顧彈性、可維護性與可擴充性。
ParkingLot#
ParkingLot 是系統核心,作為 facade 提供管理停車場關鍵操作的簡單介面。它透過下列流程管理車輛進出:
- 為入場車輛產生票券
- 透過 ParkingManager 指派車位
- 離場時呼叫 FareCalculator 計費
完整類別圖#
這張圖展示了一個看似複雜的系統是如何由簡單、設計良好的元件組合而成。
程式實作#
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 Choice:
VehicleSizeenum 標準化大小,確保型別安全的比較。替代方案的取捨:
- 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 取得車位、產生 TicketleaveVehicle(Ticket)— 設定離場時間、釋出車位、由 FareCalculator 算費
深度討論#
新增車位類型#
停車場已支援三種車位,但可能需要新增身障專用車位(HandicappedSpot)。挑戰在於遵循開放/封閉原則——擴充而不修改既有程式碼。
我們新增 HandicappedSpot 類別實作既有 ParkingSpot 介面,即可無縫整合進 ParkingManager 的分配邏輯。
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 物件的參考,將支付處理委派給該物件。
何時使用#
- 應用程式需在執行期依條件選擇不同演算法
- 類別中堆滿了選擇演算法變體的條件式
- 需要將業務邏輯與任務實作細節分離
Facade Design Pattern#
Facade 是一種結構型設計模式,為複雜子系統提供簡化介面,藏起底層複雜度。
在停車場設計中,ParkingLot 即扮演 facade 角色,協調入場、車位分配、計費等操作,將細節委派給 ParkingManager 與 FareCalculator。
問題情境:家庭劇院#
設定一個家庭劇院系統,包含 DVD player、投影機、音響、燈光等多個元件。要看電影必須一一開機、調整設定、彼此同步——使用者必須了解每個元件運作。
解法#
Facade 模式引入一個facade介面封裝子系統複雜度。例如 HomeTheaterFacade 提供 watchMovie() 方法,內部負責開投影機、設定音響等。客戶端只與 facade 互動。
何時使用#
- 子系統複雜,需要為客戶端提供更簡單的介面
- 想將系統分層為子系統,但仍提供統一入口供常見操作