CS1602Introduction to Computation
Lecture 9Part 4 Abstraction and OrganizationAI Level 1

Classes I: Objects and Encapsulation

A function packages a piece of logic. A class packages data together with the logic that acts on it. Once the things in your program start having their own state and behavior, it is time for classes.

By the end of this lecture you should be able to

  • Explain the relationship between a class and an object, and why binding data to functions helps
  • Write a class with __init__ and instance methods
  • Say what self is and why it must be written
  • Distinguish instance variables from class variables
  • Use a leading underscore to signal "this is internal"
Contents

First, try it with what you already have

Suppose you want to represent points on a plane and measure distances. With what you know now:

def make_point(x: float, y: float) -> dict[str, float]:
    return {"x": x, "y": y}

def distance(p1: dict[str, float], p2: dict[str, float]) -> float:
    return ((p1["x"] - p2["x"]) ** 2 + (p1["y"] - p2["y"]) ** 2) ** 0.5

a = make_point(0, 0)
b = make_point(3, 4)
print(distance(a, b))

It works. But several things chafe:

  • The data and the operations are separate. make_point and distance merely happen to both deal with points; nothing in the language says they belong together
  • Nothing is protected. Anyone can write a["x"] = "abc" or del a["y"], and distance falls over afterward
  • It reads awkwardly. distance(a, b) says “distance(a, b)”, not “a’s distance to b”
  • It is verbose. Every access is p["x"]

When a thing in your program has both its own data and operations that belong to it, package them together. That is a class.

Classes and objects

class Point:
    pass

p = Point()
print(p)
print(type(p))
  • A class is a template, describing what things of this kind look like and can do
  • An object is one thing built from that template, also called an instance

The relationship is that of “dog” to “your dog”. Point is the concept; p is one particular point.

You have in fact been using objects all along — L2 said type() returns the type, and every type is a class:

print(type(42), type("abc"), type([1, 2]), type({}))

"abc".upper() calls a method on a str object. You simply had not noticed.

__init__: doing something at creation time

The Point() above produces an empty object. We want the coordinates set as it is built:

class Point:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

p = Point(3, 4)
print(p.x, p.y)

__init__ is a special method that Python calls automatically when you write Point(3, 4). The double underscores on both sides are a convention meaning “this is for the interpreter, not for you to call directly”.

Its job is to initialize the new object — put every attribute it should have in place.

What self actually is

This is the lecture’s main obstacle, and it repays some attention.

Observation one: declared, but never passed

class Point:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def show(self) -> None:            # declared with a parameter
        print(f"({self.x}, {self.y})")

p = Point(3, 4)
p.show()                                # called with none

show(self) declares one parameter and p.show() supplies zero. Where did it go?

Observation two: p.show() really is Point.show(p)

class Point:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def show(self) -> None:
        print(f"({self.x}, {self.y})")

p = Point(3, 4)

p.show()             # how you normally write it
Point.show(p)        # exactly equivalent, and this is what is happening

p.show() is syntactic sugar. Python passes whatever is left of the dot as the first argument.

So self is “the object this method was called on”.

Observation three: why it has to exist

All objects of a class share one copy of the method code:

class Point:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def show(self) -> None:
        print(f"({self.x}, {self.y})")

a = Point(0, 0)
b = Point(3, 4)

print("same underlying function?", a.show.__func__ is b.show.__func__)
a.show()
b.show()

One piece of code, two different outputs — because self points somewhere different each time. Without self, a method would not know who it is working for.

The name self is not a keyword and could technically be changed. But everyone writes self, and changing it only confuses your reader — exactly as with args and kwargs in L4.

Filling the class out

class Point:
    """A point on a plane."""

    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def distance_to(self, other: "Point") -> float:
        """Distance to another point."""
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

    def move(self, dx: float, dy: float) -> None:
        """Translate this point."""
        self.x += dx
        self.y += dy

    def show(self) -> None:
        print(f"({self.x}, {self.y})")


a = Point(0, 0)
b = Point(3, 4)

print(a.distance_to(b))
a.move(1, 1)
a.show()
print(a.distance_to(b))

Set against the dictionary version:

  • a.distance_to(b) reads as “a’s distance to b”
  • data and operations sit together, so the class tells you what a point can do
  • adding a capability means adding a method, not hunting through a file

Encapsulation

Binding data to the behavior that acts on it is called encapsulation.

The problem it solves is scale. In a program of a few thousand lines, if all the data is loose dictionaries and lists and all the functions sit on one flat plane, you cannot tell what a change will affect.

With classes, the boundaries are visible:

  • want to know what a point can do → read the Point class
  • want to change how points work → change only inside Point
  • want to use a point → you need the method names, not the internal storage

This is the same argument L3 made for functions, one scale up: a function packages a piece of logic; a class packages a set of related data and logic.

A more substantial example

class BankAccount:
    """A simplified bank account."""

    def __init__(self, owner: str, balance: float = 0.0) -> None:
        self.owner = owner
        self.balance = balance
        self.history: list[str] = []

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            print("deposits must be positive")
            return
        self.balance += amount
        self.history.append(f"deposited {amount}")

    def withdraw(self, amount: float) -> bool:
        if amount > self.balance:
            print(f"insufficient funds: wanted {amount}, have {self.balance}")
            return False
        self.balance -= amount
        self.history.append(f"withdrew {amount}")
        return True

    def statement(self) -> str:
        lines = [f"{self.owner}'s account, balance {self.balance}"]
        lines.extend("  " + h for h in self.history)
        return "\n".join(lines)


acc = BankAccount("Alex", 100)
acc.deposit(50)
acc.withdraw(30)
acc.withdraw(1000)          # refused
print(acc.statement())

Notice the check inside withdraw — this is precisely what encapsulation buys. If the balance were merely a variable lying around, anyone could set it negative. Inside the class, every change goes through a method, and the method can hold the line.

Instance variables and class variables

Attributes like self.x are instance variables — one per object, independent of each other.

Sometimes you want something shared by every object, which goes directly in the class body:

class Dog:
    species = "Canis familiaris"        # class variable: shared by all dogs

    def __init__(self, name: str) -> None:
        self.name = name                 # instance variable: each dog's own

a = Dog("Rex")
b = Dog("Biscuit")

print(a.name, b.name)                    # different
print(a.species, b.species)              # the same
print(Dog.species)                       # reachable from the class too

Counting is a common use:

class Student:
    count = 0                            # how many have been created

    def __init__(self, name: str) -> None:
        self.name = name
        Student.count += 1               # note Student.count, not self.count

Student("Alex"); Student("Blake"); Student("Chen")
print("students created:", Student.count)
class BadBasket:
    items: list[str] = []
    def add(self, x: str) -> None:
        self.items.append(x)

class GoodBasket:
    def __init__(self) -> None:
        self.items: list[str] = []
    def add(self, x: str) -> None:
        self.items.append(x)

b1, b2 = BadBasket(), BadBasket()
b1.add("apple")
print("BadBasket  b2.items =", b2.items, "← leaked")

g1, g2 = GoodBasket(), GoodBasket()
g1.add("apple")
print("GoodBasket g2.items =", g2.items, "← correct")

Private, by convention

Some attributes are internal detail that outsiders should leave alone. Python’s answer is convention, not enforcement:

class Temperature:
    def __init__(self, celsius: float) -> None:
        self._celsius = celsius          # single underscore: internal, please don't

    def to_fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32

    def set_celsius(self, value: float) -> None:
        if value < -273.15:
            print("below absolute zero; ignored")
            return
        self._celsius = value


t = Temperature(25)
print(t.to_fahrenheit())
t.set_celsius(-300)                       # blocked
t.set_celsius(100)
print(t.to_fahrenheit())

A leading underscore _name means: “this is internal; do not rely on it staying the same.” Python will not stop you touching t._celsius, but doing so ignores the author’s warning — and when they change the internals, your code breaks.

A double underscore __name triggers name mangling, which makes access more awkward:

class Secret:
    def __init__(self) -> None:
        self.__hidden = 42

s = Secret()
print(s.__hidden)
class Secret:
    def __init__(self) -> None:
        self.__hidden = 42

s = Secret()
print(s._Secret__hidden)        # still reachable; the name was just rewritten

So it is not truly private either. In practice a single underscore is enough; double underscores exist mainly to stop a subclass overwriting something accidentally (L10 returns to this with inheritance).

When a class is the right answer

Not everything needs one:

Use a classDo not
the thing has state, and the state changesit is a one-off data transformation
a group of operations belongs to that dataonly one function ever touches it
there will be many instances of the same kindthere is exactly one, globally
the data’s validity needs protectingthe data is read-only anyway

Summary

  • A class is a template; an object is an instance built from it. type(x) returns x’s class
  • __init__ is called automatically at creation and initializes the attributes
  • self is the object the method was called on. p.show() is Point.show(p)
  • Forgetting self → TypeError: ... takes 0 positional arguments but 1 was given
  • Encapsulation: data and its behavior packaged together, with methods enforcing the rules
  • Instance variables are per-object; class variables are shared
  • Never make a class variable mutable — the same trap as a mutable default argument
  • A leading underscore _x marks internals — a convention, not enforcement
  • A one-method class should be a function; a data-only class should be a dictionary

Exercises

  1. Write a Rectangle class with width and height, offering area(), perimeter() and is_square().
  2. Add a scale(factor) method to Rectangle that multiplies both dimensions. Should it modify the object or return a new one? Justify your choice.
  3. Write a Counter class supporting add(item), get(item) and most_common(). Store the data in an instance variable.
  4. What is wrong with this class? Fix it:
    class Playlist:
        songs = []
        def add(self, song):
            self.songs.append(song)
  5. Write a Stack class (stacks came up in L7 when discussing the call stack) offering push, pop, peek and is_empty. What should pop do on an empty stack? Explain your design.
  6. Rewrite the results-file parsing from the L6 exercises as a Gradebook class offering add(name, subject, score), average(name) and top_student().

The lab problems for this lecture are in Lab 9.