Object-Oriented Programming: Understanding Core OOP Concepts
What is Object-Oriented Programming
Think back to when you first started programming. Initially, we often wrote code as sequential instructions—like a detailed recipe that follows steps from start to finish to complete a task. This approach is intuitive for simple programs, but when projects become large and complex, the code becomes difficult to manage and maintain.
Object-Oriented Programming (OOP) provides a completely new way of thinking. Instead of simply listing instructions, it simulates the real world, organizing programs into collaborating "objects." Just as the real world consists of various concrete entities—cars, buildings, people—OOP lets us think about code in the same way.
Core Philosophy of OOP
The core idea of object-oriented programming is to combine data and the methods that operate on that data into independent units called "objects." Each object has its own characteristics (attributes) and capabilities (methods), can work independently, and can collaborate with other objects.
Imagine you're running a coffee shop. In the real world, you wouldn't mix all materials, tools, employees, and customers together. Instead, you would:
- Organize related things together (coffee machines have their own accessories and operating methods)
- Define clear responsibilities (cashiers handle payments, baristas make coffee)
- Establish collaboration mechanisms (customer orders → cashier records → barista makes coffee)
OOP applies this real-world organizational approach to programming.
Basic Concepts of Objects and Classes
Objects
An object is the basic unit of OOP—an entity with state and behavior:
// A simple object
const coffeeMachine = {
// State (properties)
brand: "EspressoMaster",
waterLevel: 1000, // milliliters
beansLevel: 500, // grams
temperature: 92, // Celsius
isOn: false,
// Behavior (methods)
turnOn() {
this.isOn = true;
console.log(`${this.brand} is now on`);
},
turnOff() {
this.isOn = false;
console.log(`${this.brand} is now off`);
},
makeCoffee(type) {
if (!this.isOn) {
console.log("Please turn on the coffee machine first");
return null;
}
if (this.waterLevel < 50 || this.beansLevel < 20) {
console.log("Insufficient water or coffee beans, please refill");
return null;
}
// Make coffee
this.waterLevel -= 50;
this.beansLevel -= 20;
console.log(`Making ${type}...`);
return {
type: type,
temperature: this.temperature,
timestamp: new Date(),
};
},
refill(water, beans) {
this.waterLevel = Math.min(this.waterLevel + water, 2000);
this.beansLevel = Math.min(this.beansLevel + beans, 1000);
console.log(`Refilled: Water ${water}ml, Coffee beans ${beans}g`);
},
};
// Using the object
coffeeMachine.turnOn();
const coffee = coffeeMachine.makeCoffee("Espresso");
console.log(coffee);
// { type: 'Espresso', temperature: 92, timestamp: ... }
coffeeMachine.refill(500, 200);In this example, the coffeeMachine object encapsulates all the characteristics of a coffee machine (water level, bean level, temperature) and functionality (turn on/off, make coffee, refill materials) into a complete, independent unit.
Classes
If objects are specific instances, then classes are templates or blueprints for creating objects. Just as an architect's design can be used to build multiple similar houses, a class defines the common characteristics and behaviors of a type of object:
class CoffeeMachine {
// Constructor - initialization logic when creating objects
constructor(brand, maxWater = 2000, maxBeans = 1000) {
this.brand = brand;
this.maxWater = maxWater;
this.maxBeans = maxBeans;
this.waterLevel = maxWater;
this.beansLevel = maxBeans;
this.temperature = 92;
this.isOn = false;
this.coffeeCount = 0; // Track number of coffees made
}
turnOn() {
this.isOn = true;
this.#heatUp(); // Private method
console.log(`${this.brand} is now on and heating`);
}
turnOff() {
this.isOn = false;
this.temperature = 25; // Return to room temperature
console.log(`${this.brand} is now off`);
}
makeCoffee(type = "Americano") {
if (!this.isOn) {
throw new Error("Coffee machine is not turned on");
}
const requirements = this.#getCoffeeRequirements(type);
if (this.waterLevel < requirements.water) {
throw new Error(
`Insufficient water, need ${requirements.water}ml, currently only ${this.waterLevel}ml`
);
}
if (this.beansLevel < requirements.beans) {
throw new Error(
`Insufficient coffee beans, need ${requirements.beans}g, currently only ${this.beansLevel}g`
);
}
// Consume materials
this.waterLevel -= requirements.water;
this.beansLevel -= requirements.beans;
this.coffeeCount++;
console.log(`Making cup #${this.coffeeCount} of ${type}...`);
return {
id: this.coffeeCount,
type: type,
temperature: this.temperature,
volume: requirements.water,
timestamp: new Date(),
machine: this.brand,
};
}
refill(water = 0, beans = 0) {
const waterAdded = Math.min(water, this.maxWater - this.waterLevel);
const beansAdded = Math.min(beans, this.maxBeans - this.beansLevel);
this.waterLevel += waterAdded;
this.beansLevel += beansAdded;
console.log(
`Refilled: Water ${waterAdded}ml (current ${this.waterLevel}ml), Coffee beans ${beansAdded}g (current ${this.beansLevel}g)`
);
return {
waterAdded,
beansAdded,
waterLevel: this.waterLevel,
beansLevel: this.beansLevel,
};
}
getStatus() {
return {
brand: this.brand,
isOn: this.isOn,
temperature: this.temperature,
waterLevel: this.waterLevel,
beansLevel: this.beansLevel,
coffeeCount: this.coffeeCount,
waterCapacity: `${Math.round((this.waterLevel / this.maxWater) * 100)}%`,
beansCapacity: `${Math.round((this.beansLevel / this.maxBeans) * 100)}%`,
};
}
// Private methods - only used internally within the class
#heatUp() {
this.temperature = 92;
}
#getCoffeeRequirements(type) {
const recipes = {
Espresso: { water: 30, beans: 18 },
Americano: { water: 150, beans: 18 },
Latte: { water: 50, beans: 18 },
Cappuccino: { water: 50, beans: 18 },
};
return recipes[type] || recipes["Americano"];
}
}
// Use classes to create multiple object instances
const officeMachine = new CoffeeMachine("EspressoMaster Pro", 3000, 1500);
const homeMachine = new CoffeeMachine("HomeBrew Mini", 1000, 500);
// Each is an independent object
officeMachine.turnOn();
const coffee1 = officeMachine.makeCoffee("Latte");
const coffee2 = officeMachine.makeCoffee("Espresso");
homeMachine.turnOn();
const coffee3 = homeMachine.makeCoffee("Americano");
console.log(officeMachine.getStatus());
// {
// brand: 'EspressoMaster Pro',
// isOn: true,
// temperature: 92,
// coffeeCount: 2,
// ...
// }
console.log(homeMachine.getStatus());
// {
// brand: 'HomeBrew Mini',
// isOn: true,
// coffeeCount: 1,
// ...
// }The advantage of classes is that we define them once and can create multiple objects with the same structure and behavior. Although officeMachine and homeMachine are different instances, they both follow the structure defined by the CoffeeMachine class.
The Four Pillars of OOP
Object-oriented programming is built on four basic principles that help us build better code structures:
1. Encapsulation
Encapsulation combines data and methods that operate on that data, hiding implementation details from the outside. Like using a coffee machine, we don't need to know the complex mechanical structure inside; we just need to press a few buttons to get coffee.
class BankAccount {
// Private fields
#balance;
#accountNumber;
#transactionHistory;
constructor(initialBalance, accountNumber) {
this.#balance = initialBalance;
this.#accountNumber = accountNumber;
this.#transactionHistory = [];
this.#recordTransaction("OPEN", initialBalance, "Account opened");
}
// Public methods - external interface
deposit(amount) {
if (amount <= 0) {
throw new Error("Deposit amount must be greater than 0");
}
this.#balance += amount;
this.#recordTransaction("DEPOSIT", amount, "Deposit");
return {
success: true,
balance: this.#balance,
message: `Successfully deposited $${amount}`,
};
}
withdraw(amount) {
if (amount <= 0) {
throw new Error("Withdrawal amount must be greater than 0");
}
if (amount > this.#balance) {
return {
success: false,
balance: this.#balance,
message: `Insufficient balance, current balance $${this.#balance}`,
};
}
this.#balance -= amount;
this.#recordTransaction("WITHDRAW", amount, "Withdrawal");
return {
success: true,
balance: this.#balance,
message: `Successfully withdrew $${amount}`,
};
}
getBalance() {
return this.#balance;
}
getStatement(limit = 10) {
return {
accountNumber: this.#maskAccountNumber(),
currentBalance: this.#balance,
recentTransactions: this.#transactionHistory.slice(-limit),
};
}
// Private methods - internal implementation
#recordTransaction(type, amount, description) {
this.#transactionHistory.push({
type,
amount,
description,
balance: this.#balance,
timestamp: new Date(),
});
}
#maskAccountNumber() {
const num = this.#accountNumber;
return `****${num.slice(-4)}`;
}
}
const myAccount = new BankAccount(1000, "1234567890");
// Can use public methods
console.log(myAccount.deposit(500));
// { success: true, balance: 1500, message: 'Successfully deposited $500' }
console.log(myAccount.withdraw(200));
// { success: true, balance: 1300, message: 'Successfully withdrew $200' }
console.log(myAccount.getBalance());
// 1300
// Cannot directly access private fields
console.log(myAccount.#balance);
// SyntaxError: Private field '#balance' must be declared in an enclosing classEncapsulation brings several important benefits:
- Data Security: External code cannot directly modify private data
- Flexibility: Can change internal implementation without affecting external usage
- Simplified Interface: Users only need to understand public methods, not complex internal details
2. Inheritance
Inheritance allows us to create new classes based on existing ones. New classes inherit properties and methods from parent classes while adding their own features. This is like children inheriting some traits from their parents but also having their unique personalities.
// Base class - Employee
class Employee {
constructor(name, id, baseSalary) {
this.name = name;
this.id = id;
this.baseSalary = baseSalary;
this.joinDate = new Date();
}
getInfo() {
return {
name: this.name,
id: this.id,
position: this.constructor.name,
joinDate: this.joinDate,
};
}
calculateSalary() {
return this.baseSalary;
}
work() {
console.log(`${this.name} is working...`);
}
}
// Derived class - Developer
class Developer extends Employee {
constructor(name, id, baseSalary, programmingLanguages) {
super(name, id, baseSalary); // Call parent class constructor
this.programmingLanguages = programmingLanguages;
this.projects = [];
}
// Override parent method
work() {
console.log(
`${this.name} is writing ${this.programmingLanguages.join(", ")} code...`
);
}
// Add new methods
addProject(project) {
this.projects.push({
name: project,
startDate: new Date(),
});
console.log(`${this.name} joined project: ${project}`);
}
// Override salary calculation (including project bonuses)
calculateSalary() {
const projectBonus = this.projects.length * 500;
return this.baseSalary + projectBonus;
}
}
// Derived class - Manager
class Manager extends Employee {
constructor(name, id, baseSalary, department) {
super(name, id, baseSalary);
this.department = department;
this.teamMembers = [];
}
work() {
console.log(`${this.name} is managing the ${this.department} department...`);
}
addTeamMember(employee) {
this.teamMembers.push(employee);
console.log(`${employee.name} joined ${this.name}'s team`);
}
calculateSalary() {
const managementBonus = this.teamMembers.length * 300;
return this.baseSalary + managementBonus;
}
getTeamInfo() {
return {
manager: this.name,
department: this.department,
teamSize: this.teamMembers.length,
members: this.teamMembers.map((m) => m.getInfo()),
};
}
}
// Using inheritance
const dev1 = new Developer("Sarah", "D001", 5000, ["JavaScript", "Python"]);
const dev2 = new Developer("Michael", "D002", 5500, ["Java", "Go"]);
const manager = new Manager("Emma", "M001", 7000, "Engineering");
// Each class has its own specific methods
dev1.addProject("E-commerce Platform");
dev1.addProject("Mobile App");
dev2.addProject("API Gateway");
manager.addTeamMember(dev1);
manager.addTeamMember(dev2);
// Polymorphism - same method, different implementations
dev1.work();
// Sarah is writing JavaScript, Python code...
dev2.work();
// Michael is writing Java, Go code...
manager.work();
// Emma is managing the Engineering department...
// Salary calculation considers各自的特殊因素
console.log(`${dev1.name} salary: $${dev1.calculateSalary()}`);
// Sarah salary: $6000 (base $5000 + 2 projects $1000)
console.log(`${manager.name} salary: $${manager.calculateSalary()}`);
// Emma salary: $7600 (base $7000 + 2 team members $600)
console.log(manager.getTeamInfo());
// {
// manager: 'Emma',
// department: 'Engineering',
// teamSize: 2,
// members: [...]
// }3. Polymorphism
Polymorphism allows different class objects to respond differently to the same message. Although they all implement the same interface, the specific behavior can be completely different.
// Base class defines common interface
class Shape {
constructor(name) {
this.name = name;
}
// Abstract method - to be implemented by subclasses
getArea() {
throw new Error("getArea() must be implemented by subclass");
}
getPerimeter() {
throw new Error("getPerimeter() must be implemented by subclass");
}
describe() {
return `${this.name}: Area=${this.getArea().toFixed(
2
)}, Perimeter=${this.getPerimeter().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(radius) {
super("Circle");
this.radius = radius;
}
getArea() {
return Math.PI * this.radius ** 2;
}
getPerimeter() {
return 2 * Math.PI * this.radius;
}
}
class Rectangle extends Shape {
constructor(width, height) {
super("Rectangle");
this.width = width;
this.height = height;
}
getArea() {
return this.width * this.height;
}
getPerimeter() {
return 2 * (this.width + this.height);
}
}
class Triangle extends Shape {
constructor(a, b, c) {
super("Triangle");
this.a = a;
this.b = b;
this.c = c;
}
getArea() {
// Heron's formula
const s = (this.a + this.b + this.c) / 2;
return Math.sqrt(s * (s - this.a) * (s - this.b) * (s - this.c));
}
getPerimeter() {
return this.a + this.b + this.c;
}
}
// The power of polymorphism - handle different types of objects uniformly
function calculateTotalArea(shapes) {
let total = 0;
for (const shape of shapes) {
// No matter what shape, can call getArea()
total += shape.getArea();
console.log(shape.describe());
}
return total;
}
const shapes = [new Circle(5), new Rectangle(10, 6), new Triangle(3, 4, 5)];
const totalArea = calculateTotalArea(shapes);
// Circle: Area=78.54, Perimeter=31.42
// Rectangle: Area=60.00, Perimeter=32.00
// Triangle: Area=6.00, Perimeter=12.00
console.log(`Total area: ${totalArea.toFixed(2)}`);
// Total area: 144.544. Abstraction
Abstraction hides complex implementation details, exposing only necessary interfaces. It allows us to think about problems at a higher level without getting bogged down in details.
// Abstract data access layer
class DataStore {
constructor() {
if (new.target === DataStore) {
throw new Error("DataStore is an abstract class and cannot be directly instantiated");
}
}
// Abstract methods
async save(key, data) {
throw new Error("save() must be implemented");
}
async load(key) {
throw new Error("load() must be implemented");
}
async delete(key) {
throw new Error("delete() must be implemented");
}
async exists(key) {
throw new Error("exists() must be implemented");
}
}
// Concrete implementation - Local storage
class LocalStorageStore extends DataStore {
async save(key, data) {
try {
localStorage.setItem(key, JSON.stringify(data));
return { success: true, key };
} catch (error) {
return { success: false, error: error.message };
}
}
async load(key) {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
}
async delete(key) {
localStorage.removeItem(key);
return { success: true };
}
async exists(key) {
return localStorage.getItem(key) !== null;
}
}
// Concrete implementation - Memory storage
class MemoryStore extends DataStore {
constructor() {
super();
this.storage = new Map();
}
async save(key, data) {
this.storage.set(key, data);
return { success: true, key };
}
async load(key) {
return this.storage.get(key) || null;
}
async delete(key) {
this.storage.delete(key);
return { success: true };
}
async exists(key) {
return this.storage.has(key);
}
}
// High-level application code - doesn't care about specific storage method
class UserManager {
constructor(dataStore) {
this.store = dataStore; // Dependency on abstraction, not concrete implementation
}
async saveUser(user) {
const key = `user:${user.id}`;
return await this.store.save(key, user);
}
async getUser(userId) {
const key = `user:${userId}`;
return await this.store.load(key);
}
async deleteUser(userId) {
const key = `user:${userId}`;
return await this.store.delete(key);
}
async userExists(userId) {
const key = `user:${userId}`;
return await this.store.exists(key);
}
}
// Can easily switch storage implementations
const memoryManager = new UserManager(new MemoryStore());
const localManager = new UserManager(new LocalStorageStore());
// Same interface, different implementations
await memoryManager.saveUser({
id: 1,
name: "John",
email: "[email protected]",
});
await localManager.saveUser({
id: 2,
name: "Sarah",
email: "[email protected]",
});Practical Applications of OOP
Building Complex Systems
// E-commerce system example
class Product {
constructor(id, name, price, stock) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock;
}
isAvailable(quantity = 1) {
return this.stock >= quantity;
}
reduceStock(quantity) {
if (!this.isAvailable(quantity)) {
throw new Error(`Insufficient stock: ${this.name}`);
}
this.stock -= quantity;
}
}
class CartItem {
constructor(product, quantity) {
this.product = product;
this.quantity = quantity;
}
getSubtotal() {
return this.product.price * this.quantity;
}
updateQuantity(newQuantity) {
if (!this.product.isAvailable(newQuantity)) {
throw new Error(`Insufficient stock, max ${this.product.stock} can be purchased`);
}
this.quantity = newQuantity;
}
}
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(product, quantity = 1) {
const existingItem = this.items.find(
(item) => item.product.id === product.id
);
if (existingItem) {
existingItem.updateQuantity(existingItem.quantity + quantity);
} else {
if (!product.isAvailable(quantity)) {
throw new Error(`${product.name} stock insufficient`);
}
this.items.push(new CartItem(product, quantity));
}
return this.getTotal();
}
removeItem(productId) {
const index = this.items.findIndex((item) => item.product.id === productId);
if (index !== -1) {
this.items.splice(index, 1);
}
}
getTotal() {
return this.items.reduce((sum, item) => sum + item.getSubtotal(), 0);
}
checkout() {
// Check all product stock
for (const item of this.items) {
if (!item.product.isAvailable(item.quantity)) {
throw new Error(`${item.product.name} stock insufficient`);
}
}
// Reduce stock
for (const item of this.items) {
item.product.reduceStock(item.quantity);
}
const order = {
items: this.items.map((item) => ({
product: item.product.name,
quantity: item.quantity,
price: item.product.price,
subtotal: item.getSubtotal(),
})),
total: this.getTotal(),
timestamp: new Date(),
};
// Clear cart
this.items = [];
return order;
}
}
// Usage
const laptop = new Product(1, "Gaming Laptop", 1299, 10);
const mouse = new Product(2, "Wireless Mouse", 29, 50);
const keyboard = new Product(3, "Mechanical Keyboard", 89, 30);
const cart = new ShoppingCart();
cart.addItem(laptop, 1);
cart.addItem(mouse, 2);
cart.addItem(keyboard, 1);
console.log(`Cart total: $${cart.getTotal()}`);
// Cart total: $1506
const order = cart.checkout();
console.log("Order details:", order);
console.log(`Remaining stock - Laptop: ${laptop.stock}, Mouse: ${mouse.stock}`);
// Remaining stock - Laptop: 9, Mouse: 48Advantages and Trade-offs of OOP
Advantages
- Code Organization: Groups related data and behavior together, clearer code structure
- Reusability: Can reuse existing code through inheritance and composition
- Maintainability: Modifying a class's internal implementation doesn't affect other parts
- Modeling Capability: Naturally simulates real-world concepts and relationships
Things to Consider
- Don't Overdesign: Not all problems need complex class hierarchies
- Inheritance Depth: Too deep inheritance chains make code hard to understand and maintain
- Performance Considerations: Object creation and method calls have some overhead, but usually aren't bottlenecks
- Combine with Other Paradigms: JavaScript supports multiple programming paradigms, choose the most appropriate approach flexibly
Summary
Object-oriented programming is not just a programming technique, but a way of thinking. It allows us to:
- Break complex problems into manageable small parts (objects)
- Establish clear abstraction layers
- Reuse and extend existing code
- Build more maintainable large systems
Understanding the core concepts of OOP—encapsulation, inheritance, polymorphism, and abstraction—is an important foundation for mastering modern software development. In the following chapters, we'll explore in-depth the specific applications and best practices of these concepts.