Kotlin Classes — Object-Oriented Programming Guide
In this tutorial, you will learn about Kotlin Classes. We cover key concepts, practical examples, and best practices to help you master this topic.
Kotlin classes use a concise declaration syntax with primary and secondary constructors, init initialization blocks, computed properties with custom getters and setters, and companion objects that replace Java's static members.
What You'll Learn
- Declare classes with primary and secondary constructors
- Define properties with default values and custom accessors
- Use init blocks for validation and initialization
- Control visibility with private, protected, internal, and public
- Create companion objects for static-like members
- Write nested and inner classes
- Override equals, hashCode, and toString
Why It Matters
Classes are the foundation of object-oriented programming in Kotlin. The language's class syntax reduces boilerplate by 50% compared to Java. Primary constructors, default values, and property declarations combine into a single line. Init blocks ensure objects are valid after construction. Companion objects provide a clean replacement for static methods. Mastering these features lets you model real-world entities correctly and maintainably.
Real-World Use
DodaTech's data models use Kotlin classes with primary constructors and validation in init blocks. The configuration system uses companion objects for factory methods. Nested classes organize UI component definitions within host classes, keeping related code together.
Learning Path
flowchart LR A[Lambdas] --> B[Classes\nYou are here] B --> C[Inheritance] style B fill:#f90,color:#fff
Class Declaration
Kotlin classes are declared with the class keyword. The primary constructor is part of the class header.
class Person(val name: String, var age: Int)
fun main() {
val person = Person("Alice", 25)
println(person.name) // Output: Alice
println(person.age) // Output: 25
person.age = 26
println(person.age) // Output: 26
}
Output: The class declaration defines both the constructor and the properties in one line. Properties declared with val are read-only, var are mutable.
Primary Constructor and Init Blocks
The primary constructor cannot contain code. Use init blocks for validation and initialization.
class User(
val email: String,
val username: String,
var isActive: Boolean = true
) {
// Init block runs during object creation
init {
require(email.contains("@")) {
"Invalid email: $email must contain @"
}
require(username.length >= 3) {
"Username must be at least 3 characters"
}
println("User created: $username")
}
init {
// Multiple init blocks run in order
if (!isActive) {
println("Warning: Inactive user created")
}
}
}
fun main() {
val user = User("alice@example.com", "alice")
println("Active: ${user.isActive}") // Output: Active: true
// val invalid = User("bad", "ab") // Throws IllegalArgumentException
}
Output: Init blocks run during construction, validating parameters before the object is fully initialized.
Secondary Constructors
Secondary constructors use the constructor keyword and must delegate to the primary constructor.
class Rectangle {
var width: Double
var height: Double
// Primary constructor
constructor(width: Double, height: Double) {
this.width = width
this.height = height
}
// Secondary constructor (square)
constructor(side: Double) : this(side, side)
// Secondary constructor with default
constructor() : this(1.0, 1.0) {
println("Default rectangle created")
}
fun area() = width * height
}
fun main() {
val rect1 = Rectangle(5.0, 3.0)
println(rect1.area()) // Output: 15.0
val square = Rectangle(4.0)
println(square.area()) // Output: 16.0
val default = Rectangle()
println(default.area()) // Output: 1.0
}
Output: Secondary constructors provide alternative ways to create objects. They must delegate to another constructor using this().
In most cases, use default parameter values instead of secondary constructors for cleaner code.
Properties with Custom Accessors
Properties in Kotlin can have custom getters and setters.
class Temperature {
var celsius: Double = 0.0
// Custom getter for computed property
val fahrenheit: Double
get() = celsius * 9.0 / 5.0 + 32.0
// Custom setter validates values
var kelvin: Double
get() = celsius + 273.15
set(value) {
require(value >= 0.0) { "Kelvin cannot be negative" }
celsius = value - 273.15
}
// Property with custom setter only
var name: String = ""
set(value) {
println("Setting name to: $value")
field = value // Backing field
}
// Private setter
var id: String = ""
private set
}
fun main() {
val temp = Temperature()
temp.celsius = 25.0
println("C: ${temp.celsius}") // Output: C: 25.0
println("F: ${temp.fahrenheit}") // Output: F: 77.0
temp.kelvin = 300.0
println("C: ${temp.celsius}") // Output: C: 26.85
temp.name = "Room Temperature" // Output: Setting name to: Room Temperature
// temp.id = "new" // Error: setter is private
}
Output: Custom accessors compute values on access and validate on assignment. The backing field is accessed with the field keyword.
Visibility Modifiers
Kotlin has four visibility modifiers.
- private: visible inside the class only
- protected: visible in the class and subclasses
- internal: visible in the same module
- public: visible everywhere (default)
class BankAccount(
private val accountNumber: String,
internal var holderName: String,
private var balance: Double
) {
protected val accountType: String = "Checking"
fun deposit(amount: Double) {
require(amount > 0) { "Amount must be positive" }
balance += amount
println("Deposited $amount. New balance: $balance")
}
fun withdraw(amount: Double): Boolean {
if (amount > balance) return false
balance -= amount
println("Withdrew $amount. New balance: $balance")
return true
}
fun getBalance(): Double = balance
}
fun main() {
val account = BankAccount("12345", "Alice", 1000.0)
account.holderName = "Alice Smith" // OK: internal
// account.balance = 2000.0 // Error: private
// account.accountType // Error: protected
account.deposit(500.0) // Output: Deposited 500.0. New balance: 1500.0
account.withdraw(200.0) // Output: Withdrew 200.0. New balance: 1300.0
}
Output: Visibility modifiers enforce Encapsulation at compile time.
Companion Objects
Companion objects replace Java's static members. They are Singleton objects tied to the class.
class ConfigManager {
companion object {
private const val DEFAULT_TIMEOUT = 30
private const val DEFAULT_RETRIES = 3
fun createDefault(): ConfigManager = ConfigManager()
val version: String = "1.0.0"
fun loadFromFile(path: String): ConfigManager {
println("Loading config from $path")
return ConfigManager()
}
// Nested constants
const val MAX_USERS = 1000
}
var timeout: Int = DEFAULT_TIMEOUT
var retries: Int = DEFAULT_RETRIES
}
fun main() {
val config = ConfigManager.createDefault()
println(config.timeout) // Output: 30
println(ConfigManager.version) // Output: 1.0.0
println(ConfigManager.MAX_USERS) // Output: 1000
val loaded = ConfigManager.loadFromFile("/etc/app/config.json")
println(loaded.retries) // Output: 3
}
Output: Companion object members are accessed through the class name, like static members in Java.
Nested and Inner Classes
Nested classes are static by default. Inner classes hold a reference to the outer class.
class OuterClass {
private val outerData = "Outer data"
// Nested class (static, no reference to outer)
class Nested {
fun show() = "Nested class"
}
// Inner class (holds reference to outer)
inner class Inner {
fun show() = "Inner accessing: $outerData"
fun getOuter() = this@OuterClass
}
}
fun main() {
val nested = OuterClass.Nested()
println(nested.show()) // Output: Nested class
val outer = OuterClass()
val inner = outer.Inner()
println(inner.show()) // Output: Inner accessing: Outer data
}
Output: Nested classes do not access outer class members. Inner classes can access the outer class through the this@OuterClass syntax.
Data Classes (Brief)
Data classes are classes whose main purpose is to hold data.
data class Product(val id: Int, val name: String, val price: Double)
fun main() {
val p1 = Product(1, "Laptop", 999.99)
val p2 = Product(1, "Laptop", 999.99)
println(p1) // Output: Product(id=1, name=Laptop, price=999.99)
println(p1 == p2) // Output: true (structural equality)
println(p1 === p2) // Output: false (different objects)
val p3 = p1.copy(price = 899.99)
println(p3) // Output: Product(id=1, name=Laptop, price=899.99)
val (id, name, price) = p1 // Destructuring
println("$id: $name at \$$price")
}
Output: Data classes automatically provide toString, equals, hashCode, copy, and componentN functions.
Common Mistakes
Using var when val suffices for class properties: If a property does not change after construction, declare it with val. This makes the class easier to reason about.
Forgetting init blocks run in order: Multiple init blocks execute in the order they appear in the class body. Do not rely on init block order for complex logic.
Calling virtual functions in constructors or init blocks: When a virtual function is called during construction, the subclass implementation may not be fully initialized yet. Avoid this pattern.
Not using require and check for validation: Init blocks should validate parameters with require (for arguments) and check (for state). This fails fast with clear error messages.
Confusing nested and inner classes: Nested classes are static and do not hold an outer class reference. Inner classes are non-static and hold a reference. Use nested by default unless you need access to the outer instance.
Leaving properties public without encapsulation: Default visibility is public. Make properties private and expose them through functions or custom getters when you need control.
Practice Questions
- What is the difference between a primary constructor and a secondary constructor?
Answer: The primary constructor is part of the class header and cannot contain code. Secondary constructors use the constructor keyword, can contain code, and must delegate to the primary constructor.
- What does an init block do?
Answer: An init block runs during object initialization, after the primary constructor. It is used for validation, logging, and initialization logic that the primary constructor cannot contain.
- How do you create a property that is publicly readable but privately settable?
Answer: Declare the property with a private setter: var name: String = "" private set.
- What is the purpose of a companion object?
Answer: A companion object provides a place for static-like members that belong to the class rather than instances. It replaces Java's static keyword.
- Challenge: Create a Bank class that maintains a list of BankAccount objects. Each BankAccount has an account number, holder name, and balance. Implement deposit, withdraw, transfer, and a companion object Factory Method that generates sequential account numbers.
Answer:
class BankAccount private constructor(
val accountNumber: String,
var holderName: String,
var balance: Double
) {
companion object {
private var nextNumber = 1000
fun create(holder: String, initialDeposit: Double): BankAccount {
val number = "ACC-${nextNumber++}"
return BankAccount(number, holder, initialDeposit)
}
}
fun deposit(amount: Double) {
if (amount > 0) balance += amount
}
fun withdraw(amount: Double): Boolean {
return if (amount <= balance) {
balance -= amount
true
} else false
}
}
class Bank {
private val accounts = mutableListOf<BankAccount>()
fun openAccount(holder: String, deposit: Double): BankAccount {
val account = BankAccount.create(holder, deposit)
accounts.add(account)
return account
}
fun transfer(from: String, to: String, amount: Double): Boolean {
val fromAcc = accounts.find { it.accountNumber == from } ?: return false
val toAcc = accounts.find { it.accountNumber == to } ?: return false
return if (fromAcc.withdraw(amount)) {
toAcc.deposit(amount)
true
} else false
}
}
Mini Project
Build an inventory management system with classes. Requirements:
- Item class with id, name, quantity, and price
- Inventory class that manages a collection of Items
- Stock validation in init blocks (quantity cannot be negative)
- A restock() method in the companion object
- Private setters for id and price after creation
- Nested class for inventory statistics
- Use require() for parameter validation
This project reinforces all class concepts in a practical business application.
FAQ
What's Next
After mastering classes, learn inheritance to extend classes and create hierarchies. You can also explore interfaces for defining contracts.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro