Kotlin Generic Constraints
In our journey through Kotlin generics, we've learned how generics provide flexibility by allowing us to write code that works with multiple types. However, there are scenarios where we need to restrict what types can be used with our generic classes or functions. This is where generic constraints come into play.
Understanding Generic Constraints
Generic constraints (also known as bounded types) allow us to limit what types can be used as type arguments in our generic classes and functions. Instead of accepting absolutely any type, we can specify that only types with certain characteristics are allowed.
Think of constraints as setting requirements for your generic types, similar to how job postings list required qualifications for candidates.
Why Use Generic Constraints?
Before diving into how constraints work, let's understand why they're useful:
- Type Safety: Constraints ensure you only work with types that have the capabilities you need
- Access to Specific Methods: By constraining to a certain type, you can access methods specific to that type
- Semantic Correctness: They help express design intent clearly
- Avoid Runtime Errors: They catch type mismatches at compile time
Upper Bounds: The Basic Constraint
The most common form of constraint is the upper bound, which specifies that a type parameter must be or extend a particular type.
Syntax
class MyClass<T : UpperBoundType> {
// Implementation
}
fun <T : UpperBoundType> myFunction(item: T) {
// Implementation
}
The : UpperBoundType
part is what sets the constraint, requiring T
to be either UpperBoundType
or a subtype of it.
Example: A Number Processor
Let's create a class that works only with numbers:
class NumberProcessor<T : Number> {
fun process(value: T): Double {
return value.toDouble() * 2
}
}
fun main() {
// Works with various number types
val intProcessor = NumberProcessor<Int>()
println(intProcessor.process(5)) // Output: 10.0
val doubleProcessor = NumberProcessor<Double>()
println(doubleProcessor.process(5.5)) // Output: 11.0
// This would cause a compilation error:
// val stringProcessor = NumberProcessor<String>()
}
In this example, NumberProcessor
only accepts types that inherit from Number
, which includes Int
, Double
, Float
, etc. Trying to use it with String
would result in a compilation error.
Multiple Constraints with Where Clause
Sometimes a single constraint isn't enough. Kotlin allows us to specify multiple constraints using the where
clause:
fun <T> someFunction(item: T) where T : Interface1, T : Interface2 {
// Implementation using methods from both interfaces
}
Example: Comparable and Printable Items
Let's say we have two interfaces:
interface Printable {
fun prettyPrint(): String
}
// Let's create a class that implements both Comparable and our custom Printable interface
class Person(val name: String, val age: Int) : Comparable<Person>, Printable {
override fun compareTo(other: Person): Int {
return this.age - other.age
}
override fun prettyPrint(): String {
return "Person(name=$name, age=$age)"
}
}
// Function that requires both constraints
fun <T> findAndPrintMax(list: List<T>): T? where T : Comparable<T>, T : Printable {
if (list.isEmpty()) return null
val max = list.maxOrNull()
println("Max element: ${max?.prettyPrint()}")
return max
}
fun main() {
val people = listOf(
Person("Alice", 29),
Person("Bob", 31),
Person("Charlie", 25)
)
val oldest = findAndPrintMax(people)
// Output: Max element: Person(name=Bob, age=31)
}
In this example, our findAndPrintMax
function requires that the type T
implements both Comparable<T>
and Printable
. This allows us to both find the maximum element and pretty-print it.
Type Constraints for Extension Functions
Constraints are particularly useful for extension functions, allowing you to add functionality to specific types:
fun <T : Comparable<T>> List<T>.findSorted(): List<T> {
return this.sorted()
}
fun main() {
val names = listOf("Charlie", "Alice", "Bob")
println(names.findSorted()) // Output: [Alice, Bob, Charlie]
val numbers = listOf(5, 2, 8, 1)
println(numbers.findSorted()) // Output: [1, 2, 5, 8]
// This would work with any list of Comparable items
}
Nullable Type Constraints
You can also specify whether a generic type can be nullable:
// T can be any type, including nullable types
class Box<T>(var value: T)
// T must be non-null
class StrictBox<T : Any>(var value: T)
fun main() {
val box = Box<String?>(null) // OK
// This would cause a compilation error:
// val strictBox = StrictBox<String?>(null)
// But this is OK:
val strictBox = StrictBox<String>("Hello")
}
In StrictBox
, the constraint T : Any
specifies that T
must be a non-nullable type.
Real-World Example: A Repository Pattern
Let's see how we might use generic constraints in a real-world scenario by implementing a simple repository pattern:
// A base entity interface
interface Entity {
val id: Long
}
// A generic repository interface
interface Repository<T : Entity> {
fun findById(id: Long): T?
fun save(entity: T): T
fun delete(entity: T)
fun findAll(): List<T>
}
// An implementation of our entity
class User(override val id: Long, val name: String, val email: String) : Entity
// Repository implementation for Users
class UserRepository : Repository<User> {
private val storage = mutableMapOf<Long, User>()
override fun findById(id: Long): User? {
return storage[id]
}
override fun save(entity: User): User {
storage[entity.id] = entity
return entity
}
override fun delete(entity: User) {
storage.remove(entity.id)
}
override fun findAll(): List<User> {
return storage.values.toList()
}
}
fun main() {
val userRepo = UserRepository()
// Add some users
userRepo.save(User(1, "Alice", "[email protected]"))
userRepo.save(User(2, "Bob", "[email protected]"))
// Find and print a user
val user = userRepo.findById(1)
println("Found user: ${user?.name}, ${user?.email}")
// Output: Found user: Alice, [email protected]
// List all users
val allUsers = userRepo.findAll()
println("All users:")
allUsers.forEach { println("- ${it.name} (${it.email})") }
/* Output:
All users:
- Alice ([email protected])
- Bob ([email protected])
*/
}
In this example:
- We defined an
Entity
interface with anid
property - Our
Repository
interface uses the constraintT : Entity
to ensure it only works with entity types - The
UserRepository
implementation is type-safe and specific to theUser
entity type
Reified Type Parameters with Constraints
When using reified type parameters (in inline functions), you can also apply constraints:
inline fun <reified T : Number> List<Any>.filterIsInstance(): List<T> {
val result = mutableListOf<T>()
for (item in this) {
if (item is T) {
result.add(item)
}
}
return result
}
fun main() {
val mixedList = listOf(1, "hello", 2.5, "world", 3f)
val numbers = mixedList.filterIsInstance<Number>()
println(numbers) // Output: [1, 2.5, 3.0]
val integers = mixedList.filterIsInstance<Int>()
println(integers) // Output: [1]
}
This function filters a list to only include items of a specific numeric type.
Summary
Generic constraints in Kotlin add an important layer of specificity to your generic code:
- Upper bounds (
T : SomeType
) restrict generic types to a specific class or its subtypes - Multiple constraints using
where
clauses allow you to require that types implement multiple interfaces - Constraints give you access to methods and properties of the bounding type
- You can use constraints with classes, functions, and extension functions
- Constraints help catch errors at compile-time rather than runtime
Generic constraints strike a balance between the flexibility of generics and the safety of specific types. They allow you to write code that works with a range of types while still ensuring those types have the capabilities your code needs.
Exercises
- Create a
SortedList<T>
class that only accepts types that implementComparable<T>
- Write a function
<T : Number> average(list: List<T>)
that calculates the average of a list of numbers - Implement a generic
Validator<T>
interface with avalidate
method, then create specific validators forString
andInt
- Design a
JsonSerializable
interface and write a function that only accepts types implementing this interface - Create a generic
Cache<K : Any, V>
class where the key type must be non-nullable
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)