Week 1 | Lesson 5

OOP Principles & Inheritance

Encapsulation, Inheritance, Polymorphism, Abstraction, Generics



© 2026 by Monika Protivová

Programming language

and communication of intent

Communication of intent

Programming language provides means of expressing programmer's intent to a computer system.

But programming it is not just a way of giving instructions to a computer. It can also be a means of communication between humans, particularly in the context of team development, code reviews, and future maintenance of the software. Here are few points to keep in mind ...

Code Clarity

Code is more often read than it is written. Therefore, it is important to keep it clean and easily understood.


Code Consistency

Keeping your code consistent in terms of syntax, programming style and design patterns makes it easier to understand.


Documentation and Comments

Some code can become hard to understand despite our best effort. In these cases, comments and code documentation should be used to clarify the programmer's intent or communicate unintuitive information.

With AI adoption, documentation and comments have taken on a whole new meaning:

  • Comments are now prompts — AI coding tools use your comments and documentation as context. Well-documented code leads to better AI-generated suggestions, making comments a direct input to your tooling.
  • The "why" is now the human's job — AI can generate the what (working code), but it cannot generate the why (the reasoning behind a decision). As more code is AI-generated, human-written comments explaining intent become more critical than ever.

Why AI-generated code often lacks intent

AI can produce code that compiles and runs correctly -- but that doesn't mean it communicates well.

What AI is good at

  • Producing syntactically correct code quickly
  • Following common patterns it has seen in training data or in your codebase
  • Generating boilerplate and repetitive structures
  • Following established codebase patterns


What AI doesn't know

  • AI will follow patterns in your codebase - you need to set those pattern
  • AI can't recognize which patterns no longer serve their purpose
  • Why a design decision was made -- only that it was made somewhere before
  • Which parts of the code will change frequently and need to be readable

What this looks like in practice

  • Generic names -- data, result, temp instead of names that reveal purpose
  • Inconsistent style -- mixing patterns from different codebases it was trained on
  • Missing context -- no comments where a human would explain a non-obvious decision
  • Over-engineering -- adding abstractions nobody asked for, because "that's how it's usually done"
❗ ️Your job as reviewer: Code that works is not the same as code that communicates. When you review AI output, ask: would a teammate understand why this code exists, not just what it does?

Object-oriented Programming

Principles in Kotlin

Object-oriented Programming Principles in Kotlin

Remember, there are four main OOP principles:

  1. Encapsulation
  2. Inheritance
  3. Polymorphism
  4. Abstraction


We will go more into detail of each of how these are handle in Kotlin on the following slides.

Encapsulation

OOP Principle #1

What is Encapsulation

Encapsulation is a concept of controlling access to the internal state of an object, protecting it from unauthorized access and ensuring data integrity.
import java.time.LocalDate data class Assignment( val dueDate: LocalDate, val assignee: String, ) { private var finalGrade: Int? = null fun getFinalGrade(): Int? { return finalGrade } fun setFinalGrade(finalGrade: Int) { require(finalGrade in 0..100) { "Final grade must be between 0 and 100" } this.finalGrade = finalGrade } } fun main() { val assignment = Assignment(LocalDate.now(), "John Doe") assignment.setFinalGrade(90) println(assignment.getFinalGrade()) }

In Java/Kotlin, this is typically achieved using access modifiers (private, protected, internal) and getter / setter methods.

By using getter / setter methods, the class can enforce its own data validation rules to ensure it's internal state remains valid and consistent.

Kotlin Getters and Setters

Kotlin provides idiomatic syntax for custom getters and setters using the get() and set(value) keywords.
import java.time.LocalDate data class Assignment( val dueDate: LocalDate, val assignee: String, ) { var finalGrade: Int? = null set(value) { value?.let { require(it in 0..100) { "Final grade must be between 0 and 100" } } field = value } val isPassing: Boolean get() = finalGrade?.let { it >= 60 } ?: false } fun main() { val assignment = Assignment(LocalDate.now(), "John Doe") assignment.finalGrade = 90 println("Grade: ${assignment.finalGrade}") println("Passing: ${assignment.isPassing}") assignment.finalGrade = 45 println("Grade: ${assignment.finalGrade}") println("Passing: ${assignment.isPassing}") }

Kotlin properties can have custom get() and set(value) accessors directly on the property.

Use the field keyword to access the backing field inside custom accessors.

Custom get() can create computed properties that derive their value from other properties.

You can make the setter private to allow external read access while restricting write access to the class.

Inheritance

OOP Principle #2

What is Inheritance

Inheritance establishes an "is-a" relationship between two classes, where one class inherits properties and methods of the other class.
The class that inherits is called subclass and the class inherited from is called superclass.
  • The class to be inherited from must be marked as open.
  • To define inheritance, the : symbol is used followed by the superclass name, for example class Dog : Animal().
  • If a superclass has a non-default constructor, you must call super() method in the subclass constructor.
  • You can mark methods and attributes of a superclass as protected. This will make them only accessible within the same package or within subclass.
  • You can reference fields and methods in the superclass class using the super keyword.
  • To prevent inheritance, you can mark the class with final modifier.

Let's have a look at this in detail ...

Inheritance: Example

Animal is base class for all animals.
open class Animal(private val sound: String) { fun makeSound() { println(sound) } protected fun makeRawSound() { println(sound) } }
BarkingAnimal extends Animal and adds a bark method.
open class BarkingAnimal : Animal("woof") { fun bark() { makeRawSound() } }
Cat extends Animal and overrides the sound.
class Cat(sound: String) : Animal(sound)
Dog extends BarkingAnimal and does not override the sound.
class Dog : BarkingAnimal()
Bird extends Animal and adds an alternative constructor. Notice the use of super.
class Bird : Animal { constructor(song: String) : super(song) }

Inheritance: Full Example

open class Animal(private val sound: String) { fun makeSound() { println(sound) } protected fun makeRawSound() { println(sound) } } open class BarkingAnimal : Animal("woof") { fun bark() { makeRawSound() } } class Cat(sound: String) : Animal(sound) class Dog : BarkingAnimal() class Bird : Animal { constructor(song: String) : super(song) } fun main() { val cat = Cat("meow") cat.makeSound() val dog = Dog() dog.makeSound() // this would not compile, because makeRawSound is protected // dog.makeRawSound(); val bird = Bird("tweet") bird.makeSound() }

Inheritance

You can use the final modifier to prevent method overriding or class inheritance.
open class Cat { final fun meow() { println("meow"); } }

We are trying to override the meow method, the compiler will throw an error.

class MyCat: Cat() { // This will not compile override fun meow() { } }

Inheritance Pros and Cons

Pros

  • Promotes code reuse
    Inheritance allows subclasses to inherit methods and fields from superclasses which leads to a reduction in code duplication.
  • Promotes polymorphism
    Subclasses can redefine certain methods based on their requirement.
  • Hierarchy and organization
    Helps to design the software in a hierarchical manner where classes with general characteristics are at a higher level and classes with specific characteristics are at lower level.

Cons

  • Tight coupling
    A subclass is tightly coupled with its superclass. If the superclass is modified, subclasses could be affected, as they inherit methods and fields from the superclass.
  • Inheritance chain
    Inheritance often leads to long chains which could make tracking down errors in the code difficult.
  • Issues with multiple inheritance
    Kotlin does not support multiple inheritance (a class can't extend more than one class).
    However, it supports multiple interface implementation, which is a partial workaround for this issue.
  • Memory overhead
    When a subclass object is created, a separate memory space is reserved for it in addition to the separate memory space reserved for the superclass object. This might result in memory wastage if the subclass makes limited use of the superclass's features.

Composition

Composition provides a "has-a" relationship. It allows you to use object instances as fields within the other classes.

Pros

  • Results in loose coupling and improves encapsulation, because the contained objects can be easily swapped without changing the code that uses them.
  • Can be used to overcome lack of multiple inheritance in Kotlin.
  • Usually allows for better testability as well.

Cons

  • It can result in bloated classes if overused, and requires more code setup than inheritance.
  • it can be more difficult to use when requests must be delegated to the appropriate class.

Composition

class Sound(private val sound: String) { fun makeSound() { println(sound) } } class Cat(val name: String) { private val sound = Sound("meow") fun meow() { sound.makeSound() } } fun main() { val cat = Cat("Garfield") cat.meow() }

Composition class

The Sound class has no dependencies and can be reused in other classes.
class Sound(private val sound: String) { fun makeSound() { println(sound) } }

Composed class

The Cat class uses composition to include a Sound instance.
class Cat(val name: String) { private val sound = Sound("meow") fun meow() { sound.makeSound() } }

Composition and Inheritance

The two techniques can be, and often are, combined.

Both inheritance and composition have their strengths and weaknesses. Deciding when to use each can be instrumental for designing cleaner and more effective code.

open class Animal(private val sound: String) { fun makeSound() { println(sound) } protected fun makeRawSound() { println(sound) } } class Sound(private val sound: String) { fun makeSound() { println(sound) } } class Cat(val name: String, sound: Sound) : Animal(sound) fun main() { val cat = Cat("Garfield", Sound("meow")) cat.makeSound() }
Superclass - adding Sound through composition
open class Animal(private val sound: String) { fun makeSound() { println(sound) } protected fun makeRawSound() { println(sound) } }
Subclass extending Animal
class Cat(val name: String, sound: Sound) : Animal(sound)
Composed class - has no dependencies
class Sound(private val sound: String) { fun makeSound() { println(sound) } }

Inheritance Exercises

Hands-on Practice

Practice: Inheritance

Practice inheritance and class hierarchies in the Learning App.

Polymorphism

OOP Principle #3

What is polymorphism

In programming, polymorphism allows us to define one interface or method that can have multiple implementations. It means that the same method or property could exhibit different behavior in different instances of object implementing given interface.

There are two types of polymorphism:

  1. Compile-Time polymorphism

    also known as static polymorphism
  2. Run-Time polymorphism

    also known as dynamic method dispatch

Compile-time polymorphism

Compile-time polymorphism is achieved through method overloading. The correct method to call is determined by the compiler at compile time based on the method signature.

object Calculator { // method with 2 parameters fun add(a: Int, b: Int): Int { return a + b } // overloaded method with 3 parameters fun add(a: Int, b: Int, c: Int): Int { return a + b + c } } fun main() { val result1 = Calculator.add(10, 20) println(result1) val result2 = Calculator.add(10, 20, 30) println(result2) }

Method overloading = defining two or more methods in a class with the same name but different signature.

Method signature = combination of the method name, return type and the parameters.

Runtime polymorphism

Runtime polymorphism is a process in which a call to an overridden method is resolved at runtime rather than at compile-time. This mechanism allows the Java Virtual Machine (JVM) to decide which method to invoke from the class hierarchy at runtime, based on the type of object.

open class Animal { open fun makeSound() { println("(silence)") } } class Dog : Animal() { override fun makeSound() { println("woof") } } class Cat : Animal() { override fun makeSound() { println("meow") } } fun main() { val animal0 = Animal() val animal1: Animal = Dog() // Animal reference but Dog object val animal2: Animal = Cat() // Animal reference but Cat object animal0.makeSound() // prints "(silence)" animal1.makeSound() // prints "woof" animal2.makeSound() // prints "meow" }
Superclass
open class Animal { open fun makeSound() { println("(silence)") } }
Subclasses
class Dog : Animal() { override fun makeSound() { println("woof") } } class Cat : Animal() { override fun makeSound() { println("meow") } }

Abstraction

OOP Principle #4

Abstract class

Abstract is defined using the abstract keyword
and are used to define common behavior that can be inherited by subclasses.

Abstract class cannot be instantiated directly. The main purpose of an abstract class is encapsulating common behavior that can be shared among multiple subclasses, while allowing each subclass to implement its own behavior either by overriding abstract methods, adding new methods or fields, or overriding non-abstract methods.

  1. Abstract classes can have constructors, but they cannot be directly instantiated.
  2. They can contain both abstract and non-abstract methods.
  3. Abstract methods must be implemented by subclasses.
  4. Non-abstract methods can be optionally overridden by subclasses.

Abstract class

Example
/** * Abstract class definition. */ abstract class Animal( protected val sound: String // notice the protected modifier ) { /* Abstract method definition, which a subclass must implement. */ abstract fun makeSound() } /** * Subclass of Animal. * Compiler will force us to call superclass constructor! */ class Cat(sound: String): Animal(sound) { /* Compiler will force us to use override keyword! */ override fun makeSound() { println(this.sound) // referencing sound in 'this' instance } } fun main() { val cat: Animal = Cat("meow") // Animal reference but Cat object cat.makeSound() }
Abstract class definition
/** * Abstract class definition. */ abstract class Animal( protected val sound: String // notice the protected modifier ) { /* Abstract method definition, which a subclass must implement. */ abstract fun makeSound() }
Abstract class implementation
/** * Subclass of Animal. * Compiler will force us to call superclass constructor! */ class Cat(sound: String): Animal(sound) { /* Compiler will force us to use override keyword! */ override fun makeSound() { println(this.sound) // referencing sound in 'this' instance } }

Interfaces

Interface is a reference type (like class) defined with the interface modifier.

In Kotlin, an interface is a reference type similar to a class. It can contain abstract methods and properties, as well as default method implementations. Interfaces cannot store state and cannot have constructors. They are used to define a contract that classes can implement.

Therefore, interface cannot be directly instantiated, just like abstract class. You could say interface is a 100% abstract class.

  1. Interfaces are declared using the interface keyword.
  2. All methods in an interface are abstract by default, but they can also have default implementations.
  3. Interfaces can contain properties, but these properties must be abstract or have default implementations.
  4. A class implements an interface using the : symbol followed by the interface name.
  5. A class can implement multiple interfaces, allowing for a form of multiple inheritance.

Interfaces

Example
interface Animal { fun makeSound() fun move(distance: Double): Double } class Cat : Animal { override fun makeSound() { println("meow") } override fun move(distance: Double): Double { val speed = 2.0 return distance / speed } } fun main() { val cat: Animal = Cat() // Animal reference but Cat object cat.makeSound() val distance = 3.2 val movementTime = cat.move(distance) println("Cat move $distance m in $movementTime s") }
Interface
interface Animal { fun makeSound() fun move(distance: Double): Double }
Implementation
class Cat : Animal { override fun makeSound() { println("meow") } override fun move(distance: Double): Double { val speed = 2.0 return distance / speed } }

Interfaces

You can also implement multiple interfaces at once.
interface Moving { fun move(distance: Double): Double } interface Vocalizing { fun makeSound() } class Cat : Moving, Vocalizing { override fun makeSound() { println("meow") } override fun move(distance: Double): Double { val speed = 2.0 return distance / speed } } fun main() { val cat: Animal = Cat() // Animal reference but Cat object cat.makeSound() val distance = 3.2 val movementTime = cat.move(distance) println("Cat move $distance m in $movementTime s") }
Moving interface
interface Moving { fun move(distance: Double): Double }
Vocalizing interface
interface Vocalizing { fun makeSound() }
Implementation of both Moving and Vocalizing
class Cat : Moving, Vocalizing { override fun makeSound() { println("meow") } override fun move(distance: Double): Double { val speed = 2.0 return distance / speed } }

Interfaces

You can also extend interface with other interfaces. The concrete class that implements such interface will be required to implement all abstract methods.
Animal interface
interface Animal : Moving, Vocalizing { fun eat(food: String) }
Moving interface
interface Moving { fun move(distance: Double): Double }
Vocalizing interface
interface Vocalizing { fun makeSound() }
Implementation of Animal
class Cat : Animal { override fun makeSound() { println("meow") } override fun move(distance: Double): Double { val speed = 2.0 return distance / speed } override fun eat(food: String) { println("eats " + food) } }

Abstraction Exercises

Hands-on Practice

Practice: Interfaces & Abstraction

Practice interfaces, abstract classes, and generics in the Learning App.

Generics

Type Parameters and Reusability

Generics

Generics allow us to create classes, interfaces, and methods that take types as parameters.

They are a way to make our code more reusable by allowing us to use the same code with different types.

Generics are used to create classes, interfaces, and methods that operate on a type parameter.

We may have already seen generics in action with the List interface. For example, all of these methods use generics:

val list = mutableListOf<String>() list.add("Hello") list.add("World") list.forEach { println(it) }

Generics

How to define a generic class

To define a generic class, need to create a type parameter in the class definition.

class MyClass<T>(private val id: T) { fun getId(): T { return id } }

Classes may have multiple type parameters.

class MyClassEnhanced<T1, T2> { fun equals(value1: T1, value2: T2 ): Boolean { return value1 == value2 } }

Generics: Full Example

class MyClass<T>(private val id: T) { fun getId(): T { return id } } class MyClassEnhanced<T1, T2> { fun equals(value1: T1, value2: T2 ): Boolean { return value1 == value2 } } fun main() { val myClass1 = MyClass<String>("ABC") println(myClass1.getId()) val myClass2 = MyClass<Int>(123) println(myClass2.getId()) val myClassEnhanced = MyClassEnhanced<Int, Double>() println(myClassEnhanced.equals(123, 123.0)) }

Generics

How to define a generic function

Similarly, you define generic functions by adding a type parameter to the function definition.

Functions can also have multiple type parameters and the type parameters can have constraints (types).

fun <T1: Number, T2: Number> myFunction(value1: T1, value2: T2 ): Double { return value1.toDouble() + value2.toDouble() }

You can also define generic return types.

interface MyInterface<T1, T2, R> { fun myFunction(a: T1, b: T2): R }

Generics Exercises

Hands-on Practice

Part 1: Generic Shape Selector

Create a generic function that selects the greater of two shapes based on their area.

Task:

  • Create function selectGreater<T : Shape2D>(shape1: T, shape2: T): T
  • Use the bounded type parameter T : Shape2D to ensure shapes have area() method
  • Return the shape with the larger area
  • If areas are equal, return the first shape

Test your function:

  • Create a Circle(3.0) and a Rectangle(2.0, 4.0)
  • Call selectGreater to find which shape has greater area

This demonstrates bounded generics where the type parameter must implement a specific interface.

Part 2: ComparableShape Interface and Triangle

Create a generic interface for comparing shapes and implement it in a Triangle class.

Task 1: Create ComparableShape Interface

  • Create generic interface ComparableShape<T> with methods:
    • fun isEqual(other: T): Boolean
    • fun isGreater(other: T): Boolean
    • fun isSmaller(other: T): Boolean

Task 2: Create Triangle Class

  • Create class Triangle with properties a: Double, b: Double, c: Double (three sides)
  • Implement both Shape2D and ComparableShape<Triangle> interfaces
  • area() - use Heron's formula: s = (a+b+c)/2, then sqrt(s*(s-a)*(s-b)*(s-c))
  • perimeter() - returns a + b + c
  • Implement comparison methods by comparing areas of triangles

Test your implementation:

  • Create Triangle(2.0, 4.0, 3.0) and Triangle(3.0, 3.0, 3.0)
  • Print appropriate messages based on comparison results

This demonstrates generic interfaces where a class can specify itself as the type parameter.

Graded Exercises: Inheritance & OOP

Complete these exercises in the Grader App for auto-grading and feedback.

Open the Grader App, pick an exercise, write your solution, then Save Draft → Test → Review → Submit.



Assignment: Shape OOP Principles

Assignment: Shape OOP Principles

Apply OOP principles (Encapsulation, Inheritance, Polymorphism, Abstraction) using abstract classes and interfaces.

In this assignment, you'll refactor the Shape Analysis system into a proper OOP hierarchy:

  • Making AbstractShape abstract with proper encapsulation (private dimensions)
  • Creating interfaces: Shape2D (area, perimeter), Shape3D (volume, surfaceArea), Convertible (to3D)
  • Implementing 2D shapes: Circle, Rectangle, Triangle
  • Implementing 3D shapes: Sphere, Cuboid, Cylinder
  • 2D shapes implement Convertible to extrude/revolve into 3D equivalents
  • Updating CSV loader to create correct subclass based on ShapeType

Key Concepts:

  • Encapsulation - Private dimensions with controlled access
  • Inheritance - Concrete shapes extend AbstractShape
  • Polymorphism - List<Shape2D> holding different shape types
  • Abstraction - Interfaces define contracts for 2D and 3D behaviors