Classes and OOP
Kotlin’s class system admits the conventional OOP machinery: classes with primary and secondary constructors, properties (with custom getters/setters), inheritance (single-class plus multiple-interface), visibility modifiers (public/internal/protected/private), abstract classes, sealed classes (treated separately), open/final for inheritance control, companion objects for type-level members, and interface defaults. The conventional Kotlin discipline: classes are final by default — admit open for inheritable classes; methods are final by default — admit open for overridable. Data classes (treated separately) admit substantial value-type behaviour. The combination — concise primary constructors with property declarations, the final-by-default discipline, the substantial interface support, the companion object for static-like members — is the substance of Kotlin’s class-oriented surface.
Class declarations
The principal form:
class Person(val name: String, var age: Int) { // primary constructor
fun greet(): String {
return "Hello, I am $name"
}
}
val p = Person("Alice", 30)
println(p.greet())
p.age = 31 // mutate
The primary constructor is part of the class header. Parameters with val or var automatically become properties; bare parameters are constructor-only locals.
Properties
class Person {
val name: String // read-only property
var age: Int = 0 // mutable property
constructor(name: String) {
this.name = name
}
}
class Counter {
var count: Int = 0
private set // private setter (custom visibility)
val isPositive: Boolean // computed property
get() = count > 0
}
val c = Counter()
c.count // OK; public get
// c.count = 1 // ERROR: private set
Properties admit custom getters and setters:
class Temperature {
var celsius: Double = 0.0
get() = field // explicit; default behaviour
set(value) {
require(value > -273.15) { "below absolute zero" }
field = value
}
val fahrenheit: Double // computed
get() = celsius * 9 / 5 + 32
}
The field is the backing field — admits the auto-generated storage.
Constructors
Primary constructor
class Person(val name: String, var age: Int) // header — single-line form
class User(
val id: Int,
val name: String,
val email: String,
var lastLogin: Instant?
)
For initialisation logic, the init block:
class Person(val name: String) {
val nameLength: Int
init {
require(name.isNotBlank()) { "name must not be blank" }
nameLength = name.length
}
}
Secondary constructors
For substantial alternative constructors:
class Rectangle(val width: Int, val height: Int) {
constructor(side: Int) : this(side, side) // delegates to primary
constructor(parent: Rectangle) : this(parent.width, parent.height) {
// additional logic
}
}
val r = Rectangle(5, 10)
val sq = Rectangle(5) // square via secondary
The secondary constructor must delegate to the primary (directly or via another secondary) using : this(...).
Inheritance
By default, classes are final — admit no inheritance. The open keyword admits inheritance:
open class Animal(val name: String) {
open fun speak(): String = "..."
fun rest() = "$name is resting" // final by default
}
class Dog(name: String, val breed: String) : Animal(name) {
override fun speak(): String = "Woof!"
// can't override rest (final)
}
val d = Dog("Rex", "Labrador")
println(d.speak()) // "Woof!"
println(d.rest()) // "Rex is resting"
The override keyword is required — admits explicit intent.
The open class admits subclassing; open fun admits overriding.
For abstract classes and abstract members:
abstract class Shape { // abstract; cannot be instantiated
abstract fun area(): Double // must be overridden
abstract val name: String // abstract property
fun describe() = "Shape: $name, area: ${area()}"
}
class Circle(val radius: Double) : Shape() {
override val name = "Circle"
override fun area() = Math.PI * radius * radius
}
Abstract members are implicitly open.
Interfaces
interface Greetable {
fun greet(): String // abstract method
val name: String // abstract property
fun fullGreeting(): String = "Hello! ${greet()}" // default implementation
}
class Person(override val name: String) : Greetable {
override fun greet() = "I am $name"
}
val p = Person("Alice")
p.greet() // "I am Alice"
p.fullGreeting() // "Hello! I am Alice"
Interfaces admit:
- Abstract methods.
- Abstract properties (without backing field).
- Default method implementations.
- Property getters with logic (no backing field).
A class may implement multiple interfaces:
class Service : Configurable, Logger, ErrorHandler {
/* ... */
}
For conflict resolution with multiple defaults:
interface A {
fun foo() = "A"
}
interface B {
fun foo() = "B"
}
class C : A, B {
override fun foo() = super<A>.foo() + super<B>.foo() // qualify
}
println(C().foo()) // "AB"
Visibility modifiers
| Modifier | Visibility |
|---|---|
public (default) | Visible everywhere |
internal | Visible within the module |
protected | Visible in the class and subclasses |
private | Visible in the class only (or file for top-level) |
class Account(initialBalance: Double) {
var balance: Double = initialBalance
private set // private setter
private val transactions = mutableListOf<Transaction>()
internal fun audit(): List<Transaction> = transactions.toList()
fun deposit(amount: Double) {
require(amount > 0)
balance += amount
transactions.add(Transaction.Deposit(amount))
}
}
companion object
Inside a class, companion object admits class-level (rather than instance-level) members:
class User(val id: Int, val name: String) {
companion object {
const val MAX_NAME_LENGTH = 100
fun create(name: String): User {
require(name.length <= MAX_NAME_LENGTH)
return User(generateId(), name)
}
private fun generateId(): Int = /* ... */
}
}
User.MAX_NAME_LENGTH // access via class
User.create("Alice")
The companion object admits the conventional Java-style “static” members.
For named companion objects:
class User {
companion object Factory { // named
fun create(name: String): User { /* ... */ }
}
}
User.create("Alice") // implicit
User.Factory.create("Alice") // explicit
The companion object may implement interfaces:
class User private constructor(val id: Int, val name: String) {
companion object : Builder<User> {
override fun build(name: String): User = User(generateId(), name)
}
}
Methods
class Counter {
var count: Int = 0
private set
fun increment() {
count++
}
fun decrement() {
count--
}
fun reset() {
count = 0
}
override fun toString() = "Counter($count)"
}
Methods are final by default — override requires the parent to mark open (or abstract).
Inner vs nested classes
By default, nested classes do not admit access to outer-instance state:
class Outer {
private val name = "Outer"
class Nested { // does NOT have access to name
fun describe() = "I'm nested"
}
inner class Inner { // HAS access to name
fun describe() = "I'm inside $name"
}
}
Outer.Nested() // construct directly
Outer().Inner() // requires outer instance
The inner keyword admits access to outer state — at the cost of holding a reference.
this
The this refers to the enclosing receiver:
class Person(val name: String) {
fun greet() {
println("Hello, ${this.name}") // explicit this
println("Hello, $name") // implicit this
}
}
class Outer {
val name = "Outer"
inner class Inner {
val name = "Inner"
fun describe() {
println(this.name) // "Inner"
println(this@Outer.name) // "Outer"
}
}
}
The qualified this@ClassName admits accessing outer scopes from inner classes.
object declarations
Singletons:
object Logger {
private var enabled = false
fun setEnabled(value: Boolean) {
enabled = value
}
fun log(message: String) {
if (enabled) println(message)
}
}
Logger.log("hello")
Treated in Data classes and objects.
data class
For value-typed records:
data class Person(val name: String, val age: Int)
Treated in Data classes and objects.
sealed types
For closed hierarchies:
sealed class Result<out T>
class Success<T>(val value: T) : Result<T>()
class Failure(val error: String) : Result<Nothing>()
Treated in Sealed types.
Common patterns
Primary constructor with init
class Email(val address: String) {
init {
require(address.contains("@")) { "Invalid email: $address" }
}
}
Property with custom getter
class Circle(val radius: Double) {
val area: Double
get() = Math.PI * radius * radius
val diameter: Double
get() = radius * 2
}
Property with backing field
class TextField {
var text: String = ""
set(value) {
field = value.trim() // mutate via field
notifyListeners(field)
}
}
Builder with secondary constructor
class HttpRequest(
val url: String,
val method: String = "GET",
val headers: Map<String, String> = emptyMap(),
val body: ByteArray? = null
) {
constructor(url: String, method: String, headersList: List<Pair<String, String>>) :
this(url, method, headersList.toMap())
}
val req = HttpRequest(
url = "https://example.com",
method = "POST",
headers = mapOf("Content-Type" to "application/json"),
body = jsonBytes
)
Singleton via object
object DatabaseConnection {
private val connectionPool = ConnectionPool()
fun connect(): Connection = connectionPool.acquire()
fun release(connection: Connection) { connectionPool.release(connection) }
}
DatabaseConnection.connect()
Companion factory
class Token private constructor(val value: String, val expiresAt: Instant) {
companion object {
fun create(): Token {
return Token(generateRandom(), Instant.now() + 1.hours)
}
fun parse(s: String): Token? {
// ...
}
}
}
The private constructor admits factory-only construction.
Abstract class with template method
abstract class Report {
fun generate(): String = buildString {
append("==== ${title()} ====\n")
append(body())
append("\n==== End ====")
}
abstract fun title(): String
abstract fun body(): String
}
class SalesReport : Report() {
override fun title() = "Sales"
override fun body() = "Sales data: ..."
}
Interface with default
interface Logger {
val tag: String
fun debug(message: String) = println("[$tag] DEBUG: $message")
fun info(message: String) = println("[$tag] INFO: $message")
fun warn(message: String) = println("[$tag] WARN: $message")
fun error(message: String) = println("[$tag] ERROR: $message")
}
class MyService : Logger {
override val tag = "MyService"
}
MyService().info("started") // [MyService] INFO: started
Visibility-controlled API
class API {
val publicEndpoint = "/v1/public"
internal val internalEndpoint = "/v1/internal"
private val secret = "..."
fun publicMethod() { /* ... */ }
private fun privateMethod() { /* ... */ }
}
Inheritance with constructor delegation
open class Animal(val name: String) {
open fun speak() = "..."
}
class Dog(name: String, val breed: String) : Animal(name) {
override fun speak() = "Woof!"
}
class Puppy(name: String, breed: String) : Dog(name, breed) {
override fun speak() = "Yip!"
}
Multiple interface implementation
interface Drawable {
fun draw()
}
interface Resizable {
fun resize(width: Int, height: Int)
}
class Widget : Drawable, Resizable {
override fun draw() { /* ... */ }
override fun resize(width: Int, height: Int) { /* ... */ }
}
Diamond inheritance with super<>
interface A { fun foo() = "A" }
interface B { fun foo() = "B" }
class C : A, B {
override fun foo() = "${super<A>.foo()} + ${super<B>.foo()}"
}
Private constructor for factory
class UserId private constructor(val value: Int) {
companion object {
fun fromInt(n: Int): UserId? {
if (n < 0) return null
return UserId(n)
}
}
}
Inner class for tight coupling
class Container<T> {
private val items = mutableListOf<T>()
inner class Iterator { // tightly coupled to Container
private var index = 0
fun hasNext() = index < items.size
fun next() = items[index++]
}
fun iterator() = Iterator()
}
Property delegation (preview)
class Service {
val config: Config by lazy {
loadConfig() // computed on first access
}
}
Treated in Property delegation.
A note on the conventional discipline
The contemporary Kotlin OOP advice:
- Use
data classfor value-typed records. - Use
classfor substantial reference-typed entities. - Use
objectfor singletons. - Use
sealed class/sealed interfacefor closed hierarchies. - Default to
final— admitopenonly when inheritance is intended. - Use
privateconstructors withcompanion objectfor factory patterns. - Use primary constructors for substantial conciseness.
- Use
initblocks for validation. - Use computed properties for derived values.
- Use property setters with backing field for validation.
- Prefer interfaces over abstract classes — admit substantial multiple inheritance.
- Use
internalfor module-only APIs. - Use
innersparingly — pay the outer-reference cost only when needed.
The combination — primary-constructor classes with property declarations, the final-by-default inheritance discipline, the substantial interface mechanism with default methods, the companion object for type-level members, the object for singletons, the substantial visibility modifiers — is the substance of Kotlin’s class-oriented surface. The discipline produces concise, well-encapsulated code with substantial flexibility for both classical OOP and the conventional Kotlin combination of OOP and functional patterns.