Classes

In programming terms, a class serves as a blueprint for mapping and packaging data and behaviour. Classes often comprise parameters and variables for storing data, and methods and functions for determining application behaviours. Classes can be used to generate objects, which are instances of a class that can be used elsewhere in the application. For example, the below Kotlin code defines an empty class called Person. An instance of the Person class is then initialised in the main function and assigned to a variable called person:

class Person {}

fun main(args: Array<String>) {
	val person = Person()
}

And the below code demonstrates the same exercise but is written using Java instead of Kotlin:

public class Person {
	public static void main(String[ ] args) {
        Person person = new Person();
    }
}

Primary constructor

Every class contains a constructor. The constructor defines any parameters that must be supplied when an instance of the class is created. The constructor can be empty, in which case no parameters would need to be supplied when creating an instance. In programming languages such as Kotlin, you define a primary constructor, which is the default constructor for the class. For example, the below Kotlin code defines a class called Person that requires a value for a String parameter called name to be supplied when the class is initialised:

class Person(name: String) {}

fun main(args: Array<String>) {
	val person = Person("Elizabeth")
}

In other programming languages such as Java, all constructors are treated as equal and there is no differentiating between primary and secondary constructors in terms of importance. With this in mind, the Java version of the above Kotlin code would read as follows:

public class Person {
	public Person(String name) { }

    public static void main(String[ ] args) {
        Person person = new Person("Elizabeth");
    }
}

Secondary constructor

Secondary constructors allow you to expand on the capabilities of the primary constructor. For example, you can define secondary constructors that include different parameter configurations and provide alternative methods for initialising the class. In Kotlin, a secondary constructor must always delegate to the primary constructor using the this keyword. For example, the below code defines a secondary constructor that accepts a parameter for the person's age, in addition to the name parameter that is defined in the primary constructor:

class Person(name: String) {
	var age = 0
					
	constructor(name: String, age: Int) : this(name) {
        this.age = age
    }
}

fun main(args: Array<String>) {
	// Initialise the Person class using the class's primary constructor
	var person = Person("Elizabeth")
	// Prints: 0
	println(person.age)
	
	// Initialise the Person class using the class's secondary constructor
	person = Person("Elizabeth", 25)
	// Prints: 25
	println(person.age)
}

In other programming languages such as Java, all constructors are treated as equal and there is no differentiating between primary and secondary constructors in terms of importance. With this in mind, the Java version of the above Kotlin code would read as follows:

public class Person {
	public int age = 0;
	
	public Person(String name) { }
	
	public Person(String name, int age) {
		this.age = age;
	}

    public static void main(String[ ] args) {
        Person person = new Person("Elizabeth");
		// Prints: 0
		System.out.println(person.age);
		
		person = new Person("Elizabeth", 25);
		// Prints: 25
		System.out.println(person.age);
    }
}

Companion objects

In Kotlin, each class can feature a companion object. Companion objects are initialised when the enclosing class is instantiated, but can also be accessed directly without instantiating the class. Companion objects are useful for ensuring data and behaviours are available as soon as the class is initialised, and for making those data and behaviours accessible via the class's namespace. For example, the below Kotlin code demonstrates how you might construct and use a class's companion object:

class Human() {
	companion object {
		fun getSpecies(): String {
			return "Homo sapiens"
		}
	}
	
	// The enclosing class can access the data and methods of the companion object
	fun describeSpecies() {
		println("Human beings belong to the " + getSpecies() +
			" species.")
	}
}

fun main(args: Array<String>) {
	// The companion object can be accessed via the class's namespace
	// By default, the companion object is called Companion
	// Prints: Home sapiens
	println(Human.Companion.getSpecies())
    // Prints: Home sapiens
	println(Human.getSpecies())
	
	// The companion object is initialised when an object of the class is instantiated
	val person = Human()
    
	// Prints: Human beings belong to the Homo sapiens species.
	person.describeSpecies()
}

By default, the companion object of a class can be accessed using the name Companion or by omitting the name entirely.

Human.Companion.getSpecies()
				
Human.getSpecies()

It is possible, however, to provide an alternative name for the companion object, as shown below:

class Human() {
	companion object Species {
		fun getSpecies(): String {
			return "Homo sapiens"
		}
	}
}

fun main(args: Array<String>) {
	// Prints: Home sapiens
	println(Human.Species.getSpecies())
    // Prints: Home sapiens
	println(Human.getSpecies())
}

When defining a companion object, you can often treat them like any other class. For example, companion objects can inherit from classes, implement interfaces, and override their methods. Companion objects can also access the private constructors of their enclosing class.

Object expressions

Object expressions are instantiated objects of anonymous classes that do not feature a name or the class modifier. The anonymous class can inherit from classes and implement interfaces like other classes. Object expressions are useful for creating one-off class instances. The below code demonstrates how to define and use an object expression using Kotlin:

fun main(args: Array<String>) {
	// Object expression - an anonymous class instance with data and methods
	val greeter = object {
		private val greeting = "Welcome, "
		fun greetPerson(name: String) {
			println(greeting + name)
		}
	}
	
	// Prints: Welcome, John
	greeter.greetPerson("John")
}

Parameters

Classes can store data values as parameters. For example, text can be stored as a String parameter, while numbers can be stored as Integers, Floats or Doubles depending on how the number is formatted. Parameters can have different scopes. For example, private parameters are only accessible from within the parent class, while public parameters can be accessed by external classes also. In programming languages such as Kotlin, it is possible to retrieve and update the value of a parameter using only the property's name, as shown below:

class Person(name: String) {
	var age = 0
					
	constructor(name: String, age: Int) : this(name) {
        this.age = age
    }
}

fun main(args: Array<String>) {
	var person = Person("Elizabeth")
	
	// Get the Person object's age parameter
	// Prints: 0
	println(person.age)
	
	// Set the Person object's age parameter
	person.age = 25
	
	// Prints: 25
	println(person.age)
}

In some programming languages such as Java, it is often necessary to define manual getter and setter methods for interacting with a class's properties, as shown below:

public class Person {
	private int age = 0;
	
	public Person(String name) { }
	
	// Getter
	public int getAge() {
		return age;
	}

	// Setter
	public void setAge(int newAge) {
		this.age = newAge;
	}

    public static void main(String[ ] args) {
        Person person = new Person("Elizabeth");
		
		// Use the age property's getter method
		// Prints: 0
		System.out.println(person.getAge());
		
		// Use the age property's setter method
		person.setAge(25);
		// Prints: 25
		System.out.println(person.getAge());
    }
}

Abstract classes

Abstract classes are classes that contain abstract methods and/or parameters (those that have not been implemented). Abstract classes can not be used to create objects, and so must be extended by other classes. It is those subclasses that implement the abstract methods and parameters and enable objects to be created. For example, the below Java code shows how to define an abstract class called Person, which contains an abstract method called changeHairColour, that must be implemented by another class extending the Person class:

abstract class Person {
	public abstract void changeHairColour();
}

The same code, but written in Kotlin, along with an implementation of the changeHairColour method is provided below:

abstract class Person {
	abstract fun changeHairColour()
}
				
class Student: Person() {
	override fun changeHairColour() {
		println("The student's hair colour has been changed.")
	}
}

fun main(args: Array<String>) {
	// Prints: The student's hair colour has been changed.
	Student().changeHairColour()
}

Inner classes

Inner classes are classes that exist within another class. The inner class can access the data from the enclosing class and provide a means of further compartmentalising your code. For example, the below Kotlin code demonstrates how to create an inner class called Hair that can access the data from the enclosing class Person:

abstract class Person {
	abstract fun changeHairColour()
}
				
class Person(name: String) {
	val name: String
	
	init {
		this.name = name
	}
	
	inner class Hair {
		val colour = "blonde"
		
		fun getHairColour(): String {
			return "$name has $colour hair"
		}
	}
}

fun main(args: Array<String>) {
	// Prints: James has blonde hair
	println(Person("James").Hair().getHairColour())
}

Enum classes

Enum classes hold a group of constant values. They provide a useful way of providing a predefined set of options when those option values do not require extra data or information about their state. For example, the below Kotlin code demonstrates how to declare an enum class called EyeColour, which holds a collection of possible eye colours:

enum class EyeColour {
	BLUE,
	HAZEL,
	BROWN
}
				
class Person(eyeColour: EyeColour) {
	val eyeColour: EyeColour
	
	init {
		this.eyeColour = eyeColour
	}
}

fun main(args: Array<String>) {
	// Prints: HAZEL
	println(Person(EyeColour.HAZEL).eyeColour)
}

It is also possible to add properties to enum class values. For example, the below Kotlin code adds a String property to the EyeColour class called visionQuality, which describes the eyesight quality for each colour:

enum class EyeColour(val visionQuality: String) {
	BLUE("Good vision"),
	HAZEL("Bad vision"),
	BROWN("20/20 vision")
}
				
class Person(eyeColour: EyeColour) {
	val eyeColour: EyeColour
	
	init {
		this.eyeColour = eyeColour
	}
}

fun main(args: Array<String>) {
	// Prints: Bad vision
	println(Person(EyeColour.HAZEL).eyeColour.visionQuality)
}

Other programming languages use enum classes also. For example, the above could be rewritten in Java using the following code:

enum EyeColour {
	BLUE("Good vision"),
	HAZEL("Bad vision"),
	BROWN("20/20 vision");
    
	private final String visionQuality;
  
	private EyeColour(String visionQuality) {
		this.visionQuality = visionQuality;
	}
  
	public String getVisionQuality() {
 		return visionQuality;
	}
}

class Person {
	private EyeColour eyeColour;

	public Person(EyeColour eyeColour) {
    	this.eyeColour = eyeColour;
    }

    public static void main(String[ ] args) {
        Person person = new Person(EyeColour.HAZEL);
        // Prints: Bad vision
        System.out.println(person.eyeColour.getVisionQuality()); 
    }
}

Sealed classes

A sealed class is a type of abstract class that imposes a specific class hierarchy. For example, sealed classes can only be extended by a restricted range of related classes within your application. The implementation of sealed classes can vary between programming languages. For example, in Kotlin, sealed classes can only be extended by other classes within the same file/package, as shown in the below code:

// A sealed class - can only be extended by classes in the same file/package
sealed class Person(name: String) {
	val name: String
	
	init {
		this.name = name
	}
    
    abstract fun getPassportNumber(): String
}
				
class Student(name: String): Person(name) {
    override fun getPassportNumber(): String {
        return name + Random.nextInt(1000000, 9999999)
    }
}

fun main(args: Array<String>) {
	// Prints: James{randomNum}
	println(Student("James").getPassportNumber())
}

Sealed classes can also be written in Java (version 17 and upwards). In Java, you must explicitly declare each class that may extend the sealed class. The permitted classes must belong to the same module as the sealed class. Each permitted class must include one of the following modifiers:

An example of the above principles is demonstrated in the below Java code:

import java.util.concurrent.ThreadLocalRandom;

// Sealed class that can only be extended by SecretAgent and Student
abstract sealed class Person permits SecretAgent, Student {
    String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    public abstract String getPassportNumber();
}

// Final class - cannot be extended
final class SecretAgent extends Person {

    public SecretAgent(String name) {
        super(name);
    }

    @Override
    public String getPassportNumber() {
        return "That information is classified";
    }
}

// Non-sealed class - can be extended by other classes
non-sealed class Student extends Person {

    public Student(String name) {
        super(name);
    }

    @Override
    public String getPassportNumber() {
        int randomNum = ThreadLocalRandom.current().nextInt(1000000, 9999999);
        return name + randomNum;
    }
}

public class Main { 
    public static void main(String[] args) { 
        SecretAgent agent = new SecretAgent("James");
        // Prints: That information is classified
        System.out.println(agent.getPassportNumber());
        
        Student student = new Student("James");
        // Prints: James{randomNum}
        System.out.println(student.getPassportNumber());
  } 
}

Data classes

Data classes are designed to store information and feature in-built functions to help manage data. Several programming languages including Kotlin and Python support data classes. In Kotlin, the fields of information the data class supports are typically defined in the class's primary constructor. For example, the below Kotlin code defines a data class called Note that stores information about the note's title and contents.

data class Note(
   val title: String,
   val contents: String)

To create an instance of a data class, you must define a value for each field in the primary constructor. The data class instance (also referred to as an object) can be stored in a variable and accessed elsewhere in your code. The example below creates an instance of the Note class with a title value of “To-do list” and contents value of “Cut the grass and go to the shop.”. The Note object is stored in a variable called newNote:

val newNote = Note("To-do list", "Cut the grass and go to the shop.")

Kotlin data classes feature a couple of extra in-built functions. For example, you can convert the contents of a data class object to a string using the toString method:

newNote.toString()

The above code would output the following: Note(title=To-do list, contents=Cut the grass and go to the shop)

You can also copy data class objects and change their values. For example, imagine we wanted to change the contents of the note to show the to-do list is complete:

val updatedNote = newNote.copy(contents = "The to-do list is complete!")

The Note object stored in the updatedNote variable would read as follows if it was converted to a string: Note(title=To-do list, contents=The to-do list is complete!)