Skip to main content

🔧 Lesson 6.2: Properties, Methods, and Constructors

In the previous lesson you built your first classes with attributes and basic methods. Now it's time to level up: methods that return values, controlling who can access your data, string representations so your objects print nicely, and static methods that belong to the class itself rather than any single object.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Write methods that return values instead of just printing
  • Define string representations so objects display nicely
  • Control access with public / private visibility
  • Use properties (getters and setters) to protect data
  • Create static methods that belong to the class, not instances
  • Build methods that support chaining

Estimated Time: 60 minutes

Project: Build a Product class for a shopping cart system

📑 In This Lesson

Methods That Return Values

In Lesson 16, most of our methods used print to show results. That's fine for demos, but real-world methods usually return values so other code can use them.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    # ❌ Printing — other code can't use this result
    def show_area(self):
        print(self.width * self.height)

    # ✅ Returning — the caller decides what to do with it
    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

    def is_square(self):
        return self.width == self.height

r = Rectangle(5, 3)

# Now we can USE the result
a = r.area()                  # 15
p = r.perimeter()             # 16
print(f"Area: {a}, Perimeter: {p}")

# Use in expressions
total = r.area() + Rectangle(4, 4).area()
print(f"Combined area: {total}")  # 31

# Use in conditions
if r.is_square():
    print("It's a square!")
else:
    print("It's a rectangle!")  # ← this one
class Rectangle {
    constructor(width, height) {
        this.width = width;
        this.height = height;
    }

    // ❌ Printing — other code can't use this result
    showArea() {
        console.log(this.width * this.height);
    }

    // ✅ Returning — the caller decides what to do with it
    area() {
        return this.width * this.height;
    }

    perimeter() {
        return 2 * (this.width + this.height);
    }

    isSquare() {
        return this.width === this.height;
    }
}

let r = new Rectangle(5, 3);

// Now we can USE the result
let a = r.area();                  // 15
let p = r.perimeter();             // 16
console.log(`Area: ${a}, Perimeter: ${p}`);

// Use in expressions
let total = r.area() + new Rectangle(4, 4).area();
console.log(`Combined area: ${total}`);  // 31

// Use in conditions
if (r.isSquare()) {
    console.log("It's a square!");
} else {
    console.log("It's a rectangle!");  // ← this one
}
class Rectangle
{
    public double Width;
    public double Height;

    public Rectangle(double width, double height)
    {
        Width = width;
        Height = height;
    }

    // Methods with RETURN TYPES (not void)
    public double Area()
    {
        return Width * Height;
    }

    public double Perimeter()
    {
        return 2 * (Width + Height);
    }

    public bool IsSquare()
    {
        return Width == Height;
    }
}

Rectangle r = new Rectangle(5, 3);

// Now we can USE the result
double a = r.Area();                  // 15
double p = r.Perimeter();             // 16
Console.WriteLine($"Area: {a}, Perimeter: {p}");

// Use in expressions
double total = r.Area() + new Rectangle(4, 4).Area();
Console.WriteLine($"Combined area: {total}");  // 31

// Use in conditions
if (r.IsSquare())
    Console.WriteLine("It's a square!");
else
    Console.WriteLine("It's a rectangle!");  // ← this one

⚠️ C# Requires Return Types

In C#, every method must declare its return type: void (returns nothing), int, double, bool, string, etc. Python and JavaScript don't require this — they can return anything or nothing. This is part of C#'s static typing philosophy: the compiler checks your types before the program even runs.

💡 Rule of Thumb

Return when other code needs the result. Print only for debugging or user-facing output methods. A method named area() should return the number; a method named display() can print. Most methods should return.

🎓 Instructor Note: Delivery Guidance

This is a critical mindset shift for beginners. Many students (especially from Lesson 16) default to putting print() in every method. Show a concrete scenario: "What if you need to add two rectangles' areas together? If area() prints instead of returning, you can't do r1.area() + r2.area() — you get None + None." The lightbulb moment usually happens when they see return values being used in if statements and arithmetic.

String Representations

When you print an object, you get something ugly like <__main__.Player object at 0x7f...>. Every language lets you define how your object should display as a string.

class Player:
    def __init__(self, name, health=100):
        self.name = name
        self.health = health
        self.inventory = []

    # __str__ — for print() and str()
    def __str__(self):
        return f"{self.name} (HP: {self.health})"

    # __repr__ — for debugging (shows in lists, console)
    def __repr__(self):
        return f"Player('{self.name}', {self.health})"

hero = Player("Alice", 85)

# __str__ is used by print()
print(hero)        # Alice (HP: 85)

# __repr__ is used in lists and debugger
party = [Player("Alice", 85), Player("Bob", 100)]
print(party)       # [Player('Alice', 85), Player('Bob', 100)]

# str() and repr() call these directly
print(str(hero))   # Alice (HP: 85)
print(repr(hero))  # Player('Alice', 85)
class Player {
    constructor(name, health = 100) {
        this.name = name;
        this.health = health;
        this.inventory = [];
    }

    // toString() — used by template literals and String()
    toString() {
        return `${this.name} (HP: ${this.health})`;
    }
}

let hero = new Player("Alice", 85);

// toString() is called automatically in string contexts
console.log(`Hero: ${hero}`);     // Hero: Alice (HP: 85)
console.log(String(hero));        // Alice (HP: 85)

// ⚠️ console.log(hero) still shows the raw object
console.log(hero);
// Player { name: 'Alice', health: 85, inventory: [] }

// But concatenation calls toString()
console.log("Status: " + hero);  // Status: Alice (HP: 85)
class Player
{
    public string Name;
    public int Health;
    public List<string> Inventory = new();

    public Player(string name, int health = 100)
    {
        Name = name;
        Health = health;
    }

    // ToString() — override the built-in method
    public override string ToString()
    {
        return $"{Name} (HP: {Health})";
    }
}

Player hero = new Player("Alice", 85);

// ToString() is called automatically
Console.WriteLine(hero);             // Alice (HP: 85)
Console.WriteLine($"Hero: {hero}");  // Hero: Alice (HP: 85)

// Works in collections too
var party = new List<Player>
{
    new Player("Alice", 85), new Player("Bob", 100)
};
Console.WriteLine(string.Join(", ", party));
// Alice (HP: 85), Bob (HP: 100)

String Representation Quick Reference

Purpose 🐍 Python ⚡ JavaScript 🔷 C#
User-friendly display __str__ toString() override ToString()
Debug representation __repr__ No built-in equivalent No built-in equivalent
Keyword None (special name) None (special name) override

💡 Python's Two Methods

Python is unique in having both __str__ and __repr__. The rule: __str__ is for humans ("Alice with 85 HP"), __repr__ is for developers (ideally something you could paste back into code to recreate the object). When in doubt, define __repr__ first — Python falls back to it if __str__ isn't defined.

Public vs. Private

Should outside code be able to directly change player.health = -999? Probably not. Access control lets you decide which attributes and methods are accessible from outside the class.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner          # Public — anyone can read
        self._balance = balance     # "Private" by convention (single _)
        self.__pin = "1234"         # Name-mangled (double __)

    def deposit(self, amount):
        if amount > 0:
            self._balance += amount

    def get_balance(self):
        return self._balance

acct = BankAccount("Alice", 1000)

# Public — works fine
print(acct.owner)         # Alice

# "Private" — works but signals "don't touch this"
print(acct._balance)      # 1000 (Python trusts you)

# Name-mangled — harder to access (but not impossible)
# print(acct.__pin)       # AttributeError!
print(acct._BankAccount__pin)  # "1234" (Python's "mangling")

# The Pythonic way: use the method
print(acct.get_balance())  # 1000
class BankAccount {
    #balance;    // Private field (# prefix)
    #pin;

    constructor(owner, balance = 0) {
        this.owner = owner;        // Public
        this.#balance = balance;   // Private
        this.#pin = "1234";        // Private
    }

    deposit(amount) {
        if (amount > 0) this.#balance += amount;
    }

    getBalance() {
        return this.#balance;
    }
}

let acct = new BankAccount("Alice", 1000);

// Public — works fine
console.log(acct.owner);        // Alice

// Private — actually enforced!
// console.log(acct.#balance);  // SyntaxError!

// Must use the public method
console.log(acct.getBalance()); // 1000
class BankAccount
{
    public string Owner;           // Public — anyone can access
    private decimal _balance;      // Private — only this class
    private string _pin;           // Private

    public BankAccount(string owner, decimal balance = 0)
    {
        Owner = owner;
        _balance = balance;
        _pin = "1234";
    }

    public void Deposit(decimal amount)
    {
        if (amount > 0) _balance += amount;
    }

    public decimal GetBalance()
    {
        return _balance;
    }
}

BankAccount acct = new BankAccount("Alice", 1000);

// Public — works fine
Console.WriteLine(acct.Owner);        // Alice

// Private — compiler error!
// Console.WriteLine(acct._balance);  // Error: inaccessible

// Must use the public method
Console.WriteLine(acct.GetBalance()); // 1000

Access Control Comparison

Feature 🐍 Python ⚡ JavaScript 🔷 C#
Public Default (no prefix) Default (no prefix) public keyword
Private _name (convention)
__name (mangled)
#name (enforced) private keyword
Enforcement Honor system 🤝 Hard enforced 🔒 Hard enforced 🔒
Philosophy "We're all consenting adults" Strict privacy Strict privacy

💡 Why Make Things Private?

Private attributes let you control how data changes. Instead of anyone writing acct.balance = -1000000, they must use acct.deposit() or acct.withdraw() — methods that can validate the input. This is called encapsulation: hiding the internal details and exposing a controlled interface.

🎓 Instructor Note: Delivery Guidance

Python's approach often surprises students coming from other languages. Emphasize that the single underscore _balance is the community standard — it signals "internal, don't touch" but Python won't stop you. The double underscore __name does name-mangling to make accidental access harder, but it's still accessible if you try. JavaScript's # fields are relatively new (ES2022) and truly private. C# has the most traditional access control with explicit keywords.

Properties: Getters and Setters

Sometimes you want attribute-like syntax (player.health) but with method-like control (validation, computed values). That's what properties do — they look like attributes but run code behind the scenes.

class Player:
    def __init__(self, name, health=100):
        self.name = name
        self._health = health     # Internal storage

    @property
    def health(self):
        """Getter — runs when you READ player.health"""
        return self._health

    @health.setter
    def health(self, value):
        """Setter — runs when you WRITE player.health = X"""
        if value < 0:
            self._health = 0
        elif value > 100:
            self._health = 100
        else:
            self._health = value

    @property
    def is_alive(self):
        """Read-only property — no setter defined"""
        return self._health > 0

hero = Player("Alice")

# Looks like an attribute, but runs the getter/setter
hero.health = 150       # Setter clamps to 100
print(hero.health)      # 100

hero.health = -50       # Setter clamps to 0
print(hero.health)      # 0

print(hero.is_alive)    # False (read-only property)
# hero.is_alive = True  # AttributeError — no setter!
class Player {
    #health;

    constructor(name, health = 100) {
        this.name = name;
        this.#health = health;
    }

    // Getter — runs when you READ player.health
    get health() {
        return this.#health;
    }

    // Setter — runs when you WRITE player.health = X
    set health(value) {
        if (value < 0) this.#health = 0;
        else if (value > 100) this.#health = 100;
        else this.#health = value;
    }

    // Read-only property — getter only
    get isAlive() {
        return this.#health > 0;
    }
}

let hero = new Player("Alice");

// Looks like a property, but runs the getter/setter
hero.health = 150;       // Setter clamps to 100
console.log(hero.health); // 100

hero.health = -50;       // Setter clamps to 0
console.log(hero.health); // 0

console.log(hero.isAlive); // false (read-only)
// hero.isAlive = true;   // Silently ignored (no setter)
class Player
{
    public string Name;
    private int _health;

    public Player(string name, int health = 100)
    {
        Name = name;
        Health = health;  // Uses the property setter!
    }

    // Property with getter and setter
    public int Health
    {
        get { return _health; }
        set
        {
            if (value < 0) _health = 0;
            else if (value > 100) _health = 100;
            else _health = value;
        }
    }

    // Read-only property (getter only)
    public bool IsAlive => _health > 0;
}

Player hero = new Player("Alice");

// Looks like a field, but runs the getter/setter
hero.Health = 150;              // Setter clamps to 100
Console.WriteLine(hero.Health); // 100

hero.Health = -50;              // Setter clamps to 0
Console.WriteLine(hero.Health); // 0

Console.WriteLine(hero.IsAlive); // False (read-only)
// hero.IsAlive = true;          // Compile error!
graph LR A["hero.health = 150"] -->|"Setter runs"| B["Validate: 150 > 100?
Clamp to 100"] B --> C["_health = 100"] D["print(hero.health)"] -->|"Getter runs"| E["Return _health"] E --> F["Output: 100"] style A fill:#ef4444,stroke:#dc2626,color:#fff style D fill:#22c55e,stroke:#16a34a,color:#fff style C fill:#3b82f6,stroke:#2563eb,color:#fff style F fill:#3b82f6,stroke:#2563eb,color:#fff

✅ Best of Both Worlds

Properties give you the simple syntax of direct attribute access (hero.health = 50) with the safety of method validation. The caller doesn't need to know or care that a getter/setter is running behind the scenes.

Static Methods

Regular methods operate on a specific object (hero.take_damage()). Static methods belong to the class itself — they don't need an instance. They're useful for utility functions, factory methods, and operations that relate to the class but not to any particular object.

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def fahrenheit(self):
        return self.celsius * 9/5 + 32

    def __str__(self):
        return f"{self.celsius}°C ({self.fahrenheit:.1f}°F)"

    # Static method — no self, no instance needed
    @staticmethod
    def from_fahrenheit(f):
        """Factory method: create a Temperature from °F"""
        return Temperature((f - 32) * 5/9)

    @staticmethod
    def boiling_point():
        return Temperature(100)

    @staticmethod
    def freezing_point():
        return Temperature(0)

# Call static methods on the CLASS, not an instance
water_boils = Temperature.boiling_point()
print(water_boils)  # 100°C (212.0°F)

body_temp = Temperature.from_fahrenheit(98.6)
print(body_temp)    # 37.0°C (98.6°F)

# Regular usage still works
today = Temperature(28)
print(today)        # 28°C (82.4°F)
class Temperature {
    constructor(celsius) {
        this.celsius = celsius;
    }

    get fahrenheit() {
        return this.celsius * 9/5 + 32;
    }

    toString() {
        return `${this.celsius}°C (${this.fahrenheit.toFixed(1)}°F)`;
    }

    // Static methods — called on the class
    static fromFahrenheit(f) {
        return new Temperature((f - 32) * 5/9);
    }

    static boilingPoint() {
        return new Temperature(100);
    }

    static freezingPoint() {
        return new Temperature(0);
    }
}

// Call static methods on the CLASS
let waterBoils = Temperature.boilingPoint();
console.log(`${waterBoils}`);  // 100°C (212.0°F)

let bodyTemp = Temperature.fromFahrenheit(98.6);
console.log(`${bodyTemp}`);    // 37.0°C (98.6°F)

// Regular usage
let today = new Temperature(28);
console.log(`${today}`);       // 28°C (82.4°F)
class Temperature
{
    public double Celsius;

    public Temperature(double celsius)
    {
        Celsius = celsius;
    }

    public double Fahrenheit => Celsius * 9.0 / 5 + 32;

    public override string ToString()
    {
        return $"{Celsius}°C ({Fahrenheit:F1}°F)";
    }

    // Static methods — called on the class
    public static Temperature FromFahrenheit(double f)
    {
        return new Temperature((f - 32) * 5.0 / 9);
    }

    public static Temperature BoilingPoint()
    {
        return new Temperature(100);
    }

    public static Temperature FreezingPoint()
    {
        return new Temperature(0);
    }
}

// Call static methods on the CLASS
Temperature waterBoils = Temperature.BoilingPoint();
Console.WriteLine(waterBoils);  // 100°C (212.0°F)

Temperature bodyTemp = Temperature.FromFahrenheit(98.6);
Console.WriteLine(bodyTemp);    // 37.0°C (98.6°F)

Temperature today = new Temperature(28);
Console.WriteLine(today);       // 28°C (82.4°F)

When to Use Static Methods

Use Case Example Why Static?
Factory methods Temperature.from_fahrenheit(98.6) Alternative way to create instances
Well-known constants Temperature.boiling_point() Predefined values that don't vary per instance
Utility functions MathHelper.clamp(value, min, max) Pure calculation, no instance state needed
Validation Email.is_valid("test@example.com") Check data before creating an instance
🎓 Instructor Note: Delivery Guidance

The key distinction: regular methods need an object (hero.heal()), static methods need the class (Temperature.from_fahrenheit()). A good way to decide: "Does this method need to access self/this?" If no → consider making it static. The factory pattern (from_fahrenheit) is especially useful — students will encounter it in real codebases. Python also has @classmethod which receives cls instead of self, but for beginners, @staticmethod is sufficient.

Method Chaining

If a method returns self / this, you can call another method on the result — creating a fluent chain of operations.

class QueryBuilder:
    def __init__(self, table):
        self.table = table
        self._conditions = []
        self._order = None
        self._limit = None

    def where(self, condition):
        self._conditions.append(condition)
        return self  # ← Return self for chaining!

    def order_by(self, field):
        self._order = field
        return self

    def limit(self, n):
        self._limit = n
        return self

    def build(self):
        query = f"SELECT * FROM {self.table}"
        if self._conditions:
            query += " WHERE " + " AND ".join(self._conditions)
        if self._order:
            query += f" ORDER BY {self._order}"
        if self._limit:
            query += f" LIMIT {self._limit}"
        return query

# Without chaining — verbose
q = QueryBuilder("users")
q.where("age > 18")
q.where("active = true")
q.order_by("name")
q.limit(10)
print(q.build())

# With chaining — clean and readable!
query = (QueryBuilder("users")
    .where("age > 18")
    .where("active = true")
    .order_by("name")
    .limit(10)
    .build())

print(query)
# SELECT * FROM users WHERE age > 18 AND active = true ORDER BY name LIMIT 10
class QueryBuilder {
    constructor(table) {
        this.table = table;
        this._conditions = [];
        this._order = null;
        this._limit = null;
    }

    where(condition) {
        this._conditions.push(condition);
        return this;  // ← Return this for chaining!
    }

    orderBy(field) {
        this._order = field;
        return this;
    }

    limit(n) {
        this._limit = n;
        return this;
    }

    build() {
        let query = `SELECT * FROM ${this.table}`;
        if (this._conditions.length)
            query += ` WHERE ${this._conditions.join(" AND ")}`;
        if (this._order) query += ` ORDER BY ${this._order}`;
        if (this._limit) query += ` LIMIT ${this._limit}`;
        return query;
    }
}

// Chained — clean and readable!
let query = new QueryBuilder("users")
    .where("age > 18")
    .where("active = true")
    .orderBy("name")
    .limit(10)
    .build();

console.log(query);
// SELECT * FROM users WHERE age > 18 AND active = true ORDER BY name LIMIT 10
class QueryBuilder
{
    private string _table;
    private List<string> _conditions = new();
    private string? _order;
    private int? _limit;

    public QueryBuilder(string table) { _table = table; }

    public QueryBuilder Where(string condition)
    {
        _conditions.Add(condition);
        return this;  // ← Return this for chaining!
    }

    public QueryBuilder OrderBy(string field)
    {
        _order = field;
        return this;
    }

    public QueryBuilder Limit(int n)
    {
        _limit = n;
        return this;
    }

    public string Build()
    {
        string query = $"SELECT * FROM {_table}";
        if (_conditions.Count > 0)
            query += $" WHERE {string.Join(" AND ", _conditions)}";
        if (_order != null) query += $" ORDER BY {_order}";
        if (_limit != null) query += $" LIMIT {_limit}";
        return query;
    }
}

// Chained!
string query = new QueryBuilder("users")
    .Where("age > 18")
    .Where("active = true")
    .OrderBy("name")
    .Limit(10)
    .Build();

Console.WriteLine(query);

💡 The Secret: return self / return this

Method chaining works because each method returns the object itself. So .where("age > 18") returns the same QueryBuilder, and you can immediately call .order_by("name") on it. You've already seen this pattern — jQuery, LINQ, and many libraries use it.

Putting It All Together

Let's build a complete Product class that uses everything from this lesson: return values, string representations, private data, properties, and a static factory method.

class Product:
    _tax_rate = 0.08  # Class-level constant

    def __init__(self, name, price, quantity=0):
        self.name = name
        self._price = 0      # Will be set via property
        self.price = price    # Use property setter for validation
        self._quantity = 0
        self.quantity = quantity

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = round(value, 2)

    @property
    def quantity(self):
        return self._quantity

    @quantity.setter
    def quantity(self, value):
        if value < 0:
            raise ValueError("Quantity cannot be negative")
        self._quantity = value

    @property
    def subtotal(self):
        return round(self._price * self._quantity, 2)

    @property
    def total_with_tax(self):
        return round(self.subtotal * (1 + Product._tax_rate), 2)

    @property
    def in_stock(self):
        return self._quantity > 0

    def __str__(self):
        stock = "In Stock" if self.in_stock else "Out of Stock"
        return f"{self.name}: ${self.price:.2f} ({stock})"

    def __repr__(self):
        return f"Product('{self.name}', {self.price}, {self.quantity})"

    @staticmethod
    def from_dict(data):
        return Product(data["name"], data["price"], data.get("quantity", 0))

# Usage
laptop = Product("Laptop", 999.99, 5)
print(laptop)                # Laptop: $999.99 (In Stock)
print(f"Subtotal: ${laptop.subtotal}")       # $4999.95
print(f"With tax: ${laptop.total_with_tax}") # $5399.95

# From dictionary (static factory)
data = {"name": "Mouse", "price": 29.99, "quantity": 50}
mouse = Product.from_dict(data)
print(mouse)  # Mouse: $29.99 (In Stock)

# Property validation
# laptop.price = -100  # ValueError: Price cannot be negative
class Product {
    static #taxRate = 0.08;
    #price;
    #quantity;

    constructor(name, price, quantity = 0) {
        this.name = name;
        this.#price = 0;
        this.price = price;     // Use setter
        this.#quantity = 0;
        this.quantity = quantity;
    }

    get price() { return this.#price; }
    set price(value) {
        if (value < 0) throw new Error("Price cannot be negative");
        this.#price = Math.round(value * 100) / 100;
    }

    get quantity() { return this.#quantity; }
    set quantity(value) {
        if (value < 0) throw new Error("Quantity cannot be negative");
        this.#quantity = value;
    }

    get subtotal() {
        return Math.round(this.#price * this.#quantity * 100) / 100;
    }

    get totalWithTax() {
        return Math.round(this.subtotal * (1 + Product.#taxRate) * 100) / 100;
    }

    get inStock() { return this.#quantity > 0; }

    toString() {
        let stock = this.inStock ? "In Stock" : "Out of Stock";
        return `${this.name}: $${this.price.toFixed(2)} (${stock})`;
    }

    static fromObject(data) {
        return new Product(data.name, data.price, data.quantity ?? 0);
    }
}

let laptop = new Product("Laptop", 999.99, 5);
console.log(`${laptop}`);
console.log(`Subtotal: $${laptop.subtotal}`);
console.log(`With tax: $${laptop.totalWithTax}`);

let mouse = Product.fromObject({ name: "Mouse", price: 29.99, quantity: 50 });
console.log(`${mouse}`);
class Product
{
    private static readonly double TaxRate = 0.08;

    public string Name;
    private double _price;
    private int _quantity;

    public Product(string name, double price, int quantity = 0)
    {
        Name = name;
        Price = price;        // Use property setter
        Quantity = quantity;
    }

    public double Price
    {
        get => _price;
        set
        {
            if (value < 0) throw new ArgumentException("Price cannot be negative");
            _price = Math.Round(value, 2);
        }
    }

    public int Quantity
    {
        get => _quantity;
        set
        {
            if (value < 0) throw new ArgumentException("Quantity cannot be negative");
            _quantity = value;
        }
    }

    public double Subtotal => Math.Round(_price * _quantity, 2);
    public double TotalWithTax => Math.Round(Subtotal * (1 + TaxRate), 2);
    public bool InStock => _quantity > 0;

    public override string ToString()
    {
        string stock = InStock ? "In Stock" : "Out of Stock";
        return $"{Name}: ${Price:F2} ({stock})";
    }

    public static Product FromDictionary(Dictionary<string, object> data)
    {
        string name = (string)data["name"];
        double price = Convert.ToDouble(data["price"]);
        int qty = data.ContainsKey("quantity") ? Convert.ToInt32(data["quantity"]) : 0;
        return new Product(name, price, qty);
    }
}

Product laptop = new Product("Laptop", 999.99, 5);
Console.WriteLine(laptop);
Console.WriteLine($"Subtotal: ${laptop.Subtotal}");
Console.WriteLine($"With tax: ${laptop.TotalWithTax}");

Exercises

🏋️ Exercise 1: Task Manager

Objective: Create a Task class with:

  • Constructor: title (string), optional priority (1–5, default 3)
  • Private completed status (boolean, starts false)
  • Property for priority that validates 1–5 range
  • complete() method that marks the task done
  • __str__ / toString that shows: [✓] Buy groceries (Priority: 2) or [ ] Buy groceries (Priority: 2)
  • Static method from_string("Buy groceries|2") that parses a pipe-separated string
✅ Solution
class Task:
    def __init__(self, title, priority=3):
        self.title = title
        self._priority = 3
        self.priority = priority  # Use setter
        self._completed = False

    @property
    def priority(self):
        return self._priority

    @priority.setter
    def priority(self, value):
        if not 1 <= value <= 5:
            raise ValueError("Priority must be 1-5")
        self._priority = value

    @property
    def completed(self):
        return self._completed

    def complete(self):
        self._completed = True

    def __str__(self):
        check = "✓" if self._completed else " "
        return f"[{check}] {self.title} (Priority: {self._priority})"

    def __repr__(self):
        return f"Task('{self.title}', {self._priority})"

    @staticmethod
    def from_string(s):
        parts = s.split("|")
        title = parts[0].strip()
        priority = int(parts[1].strip()) if len(parts) > 1 else 3
        return Task(title, priority)

# Test
tasks = [
    Task("Buy groceries", 2),
    Task("Write report", 5),
    Task.from_string("Call dentist|4"),
    Task.from_string("Read book")
]

tasks[0].complete()

for task in tasks:
    print(task)
# [✓] Buy groceries (Priority: 2)
# [ ] Write report (Priority: 5)
# [ ] Call dentist (Priority: 4)
# [ ] Read book (Priority: 3)
class Task {
    #priority;
    #completed = false;

    constructor(title, priority = 3) {
        this.title = title;
        this.#priority = 3;
        this.priority = priority;
    }

    get priority() { return this.#priority; }
    set priority(value) {
        if (value < 1 || value > 5) throw new Error("Priority must be 1-5");
        this.#priority = value;
    }

    get completed() { return this.#completed; }

    complete() { this.#completed = true; }

    toString() {
        let check = this.#completed ? "✓" : " ";
        return `[${check}] ${this.title} (Priority: ${this.#priority})`;
    }

    static fromString(s) {
        let parts = s.split("|");
        let title = parts[0].trim();
        let priority = parts.length > 1 ? parseInt(parts[1].trim()) : 3;
        return new Task(title, priority);
    }
}

let tasks = [
    new Task("Buy groceries", 2),
    new Task("Write report", 5),
    Task.fromString("Call dentist|4"),
    Task.fromString("Read book")
];

tasks[0].complete();
tasks.forEach(t => console.log(`${t}`));
class Task
{
    public string Title;
    private int _priority;
    private bool _completed = false;

    public Task(string title, int priority = 3)
    {
        Title = title;
        Priority = priority;
    }

    public int Priority
    {
        get => _priority;
        set
        {
            if (value < 1 || value > 5)
                throw new ArgumentException("Priority must be 1-5");
            _priority = value;
        }
    }

    public bool Completed => _completed;
    public void Complete() => _completed = true;

    public override string ToString()
    {
        string check = _completed ? "✓" : " ";
        return $"[{check}] {Title} (Priority: {_priority})";
    }

    public static Task FromString(string s)
    {
        var parts = s.Split("|");
        string title = parts[0].Trim();
        int priority = parts.Length > 1 ? int.Parse(parts[1].Trim()) : 3;
        return new Task(title, priority);
    }
}

var tasks = new List<Task>
{
    new Task("Buy groceries", 2),
    new Task("Write report", 5),
    Task.FromString("Call dentist|4"),
    Task.FromString("Read book")
};

tasks[0].Complete();
tasks.ForEach(t => Console.WriteLine(t));

🏋️ Exercise 2: Shopping Cart

Objective: Build a ShoppingCart class that holds Product objects:

  • Constructor takes an owner name
  • add(product) — adds a Product to the cart, returns self/this for chaining
  • remove(product_name) — removes a product by name
  • total (property) — returns the sum of all product subtotals
  • item_count (property) — number of items
  • __str__ / toString — shows cart summary
✅ Solution
class Product:
    def __init__(self, name, price, quantity=1):
        self.name = name
        self.price = price
        self.quantity = quantity

    @property
    def subtotal(self):
        return round(self.price * self.quantity, 2)

    def __str__(self):
        return f"{self.name} x{self.quantity} = ${self.subtotal:.2f}"

class ShoppingCart:
    def __init__(self, owner):
        self.owner = owner
        self._items = []

    def add(self, product):
        self._items.append(product)
        return self  # Chaining!

    def remove(self, product_name):
        self._items = [p for p in self._items if p.name != product_name]
        return self

    @property
    def total(self):
        return round(sum(p.subtotal for p in self._items), 2)

    @property
    def item_count(self):
        return len(self._items)

    def __str__(self):
        lines = [f"🛒 {self.owner}'s Cart ({self.item_count} items):"]
        for item in self._items:
            lines.append(f"  • {item}")
        lines.append(f"  Total: ${self.total:.2f}")
        return "\n".join(lines)

# Test with chaining
cart = (ShoppingCart("Alice")
    .add(Product("Laptop", 999.99))
    .add(Product("Mouse", 29.99, 2))
    .add(Product("USB Cable", 9.99, 3)))

print(cart)
# 🛒 Alice's Cart (3 items):
#   • Laptop x1 = $999.99
#   • Mouse x2 = $59.98
#   • USB Cable x3 = $29.97
#   Total: $1089.94

cart.remove("Mouse")
print(f"\nAfter removing Mouse: ${cart.total:.2f}")  # $1029.96
class Product {
    constructor(name, price, quantity = 1) {
        this.name = name;
        this.price = price;
        this.quantity = quantity;
    }

    get subtotal() {
        return Math.round(this.price * this.quantity * 100) / 100;
    }

    toString() {
        return `${this.name} x${this.quantity} = $${this.subtotal.toFixed(2)}`;
    }
}

class ShoppingCart {
    #items = [];

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

    add(product) {
        this.#items.push(product);
        return this;
    }

    remove(productName) {
        this.#items = this.#items.filter(p => p.name !== productName);
        return this;
    }

    get total() {
        return Math.round(
            this.#items.reduce((sum, p) => sum + p.subtotal, 0) * 100
        ) / 100;
    }

    get itemCount() { return this.#items.length; }

    toString() {
        let lines = [`🛒 ${this.owner}'s Cart (${this.itemCount} items):`];
        this.#items.forEach(item => lines.push(`  • ${item}`));
        lines.push(`  Total: $${this.total.toFixed(2)}`);
        return lines.join("\n");
    }
}

// Test with chaining
let cart = new ShoppingCart("Alice")
    .add(new Product("Laptop", 999.99))
    .add(new Product("Mouse", 29.99, 2))
    .add(new Product("USB Cable", 9.99, 3));

console.log(`${cart}`);

cart.remove("Mouse");
console.log(`\nAfter removing Mouse: $${cart.total.toFixed(2)}`);
class Product
{
    public string Name;
    public double Price;
    public int Quantity;

    public Product(string name, double price, int quantity = 1)
    { Name = name; Price = price; Quantity = quantity; }

    public double Subtotal => Math.Round(Price * Quantity, 2);
    public override string ToString() =>
        $"{Name} x{Quantity} = ${Subtotal:F2}";
}

class ShoppingCart
{
    public string Owner;
    private List<Product> _items = new();

    public ShoppingCart(string owner) { Owner = owner; }

    public ShoppingCart Add(Product product)
    { _items.Add(product); return this; }

    public ShoppingCart Remove(string productName)
    { _items.RemoveAll(p => p.Name == productName); return this; }

    public double Total => Math.Round(_items.Sum(p => p.Subtotal), 2);
    public int ItemCount => _items.Count;

    public override string ToString()
    {
        var lines = new List<string>
            { $"🛒 {Owner}'s Cart ({ItemCount} items):" };
        _items.ForEach(item => lines.Add($"  • {item}"));
        lines.Add($"  Total: ${Total:F2}");
        return string.Join("\n", lines);
    }
}

var cart = new ShoppingCart("Alice")
    .Add(new Product("Laptop", 999.99))
    .Add(new Product("Mouse", 29.99, 2))
    .Add(new Product("USB Cable", 9.99, 3));

Console.WriteLine(cart);
cart.Remove("Mouse");
Console.WriteLine($"\nAfter removing Mouse: ${cart.Total:F2}");
🎓 Instructor Note: Delivery Guidance

Exercise 1 (Task) covers properties, validation, string representation, and static factory methods in one focused class. Exercise 2 (ShoppingCart) is the capstone — it uses composition (a cart containing products), method chaining, computed properties, and ties back to collections. Challenge fast students: add a discount(percent) method to ShoppingCart, or make the cart prevent duplicate products by updating quantity instead of adding a new entry.

Summary

🎉 Key Takeaways

  • Return values: Methods should usually return results, not print them — let the caller decide what to do
  • String representation: Python __str__/__repr__, JS toString(), C# override ToString()
  • Public vs. Private: Python uses convention (_name), JS uses #name, C# uses private keyword
  • Properties give attribute syntax with method control: Python @property, JS get/set, C# property syntax
  • Static methods belong to the class, not instances: Python @staticmethod, JS/C# static
  • Method chaining works by returning self/this from methods
  • Encapsulation = hiding data + exposing a controlled interface

🚀 What's Next?

You've built complete, well-structured classes with controlled access and useful methods. In the next lesson, we tackle the most powerful OOP concept: inheritance and polymorphism — creating new classes that build on existing ones, sharing behavior while adding specialization.

🎯 Quick Check

Question 1: In Python, what decorator creates a property with a getter?

Question 2: How do you declare a private field in JavaScript?

Question 3: What must a method return to support chaining?