Kotlin ? and !! Operators Explained

Okay, here’s a comprehensive article on Kotlin’s ? (safe call) and !! (not-null assertion) operators, clocking in at around 5000 words. I’ve aimed for extreme detail, covering not just the mechanics but also the philosophy, best practices, and alternatives.


Kotlin’s ? and !! Operators: A Deep Dive into Null Safety

Kotlin’s approach to null safety is one of its most lauded features, distinguishing it from languages like Java where NullPointerExceptions are a common and often dreaded occurrence. At the heart of this system lie two crucial operators: the safe call operator (?) and the not-null assertion operator (!!). This article will explore these operators in exhaustive detail, covering their syntax, semantics, use cases, potential pitfalls, and alternatives. We’ll also delve into the broader philosophy of Kotlin’s null safety system and how these operators fit into that design.

1. The Problem of Null: Why Null Safety Matters

Before we dive into the specifics of the operators, it’s crucial to understand why Kotlin places such a strong emphasis on null safety. The concept of “null” (or nil in some languages) was introduced by Tony Hoare, who later called it his “billion-dollar mistake.” The core problem is that null represents the absence of a value, and this absence can be easily overlooked by developers.

In many languages, any variable of a reference type (e.g., an object) can potentially hold the value null. If you attempt to access a member (a property or method) of a variable that happens to be null, you’ll get a runtime exception – typically a NullPointerException in Java, or a similar error in other languages. These exceptions are often:

  • Unexpected: They can occur in seemingly innocuous code, especially when dealing with external data sources, complex object graphs, or asynchronous operations.
  • Difficult to Debug: Tracking down the root cause of a NullPointerException can be time-consuming, requiring careful examination of the program’s state and execution flow.
  • Production Issues: NullPointerExceptions that slip through testing can lead to crashes and unexpected behavior in production, impacting users.

Traditional approaches to handling null often involve defensive programming, where developers pepper their code with null checks:

java
// Java example (without Kotlin's null safety)
String myString = getSomeString();
if (myString != null) {
int length = myString.length();
// ... use the length
} else {
// Handle the null case
}

This approach is verbose, error-prone (it’s easy to miss a check), and reduces code readability. Kotlin’s null safety system aims to solve these problems at the language level.

2. Kotlin’s Nullable Types: The Foundation

Kotlin’s null safety is built upon the concept of nullable types. By default, types in Kotlin are non-nullable. This means that a variable of type String, for example, cannot hold the value null. If you try to assign null to a non-nullable variable, you’ll get a compile-time error:

kotlin
val myString: String = "Hello"
// myString = null // Compile-time error!

To indicate that a variable can hold null, you must explicitly mark its type as nullable by adding a question mark (?) after the type name:

kotlin
val myNullableString: String? = null // This is allowed

This seemingly small change has profound implications. The compiler now enforces that you handle the possibility of null whenever you work with a nullable type. You can’t accidentally forget, because the code simply won’t compile if you do. This compile-time checking is the cornerstone of Kotlin’s null safety.

3. The Safe Call Operator (?)

The safe call operator (?.) is the primary tool for working with nullable types in a safe and concise manner. It allows you to access a member (property or method) of a nullable variable only if the variable is not null. If the variable is null, the entire expression evaluates to null.

Syntax:

kotlin
nullableVariable?.member

Semantics:

  1. Check for Null: The ?. operator first checks if nullableVariable is null.
  2. Conditional Access:
    • If nullableVariable is not null, the operator accesses the member (property or method) and returns its value. The type of the result is the same as the type of the member.
    • If nullableVariable is null, the operator short-circuits the access and returns null. The type of the result is the nullable version of the member‘s type.

Example:

“`kotlin
val myString: String? = “Hello”
val length: Int? = myString?.length // length will be 5 (Int?)

val anotherString: String? = null
val anotherLength: Int? = anotherString?.length // anotherLength will be null (Int?)
“`

Key Points:

  • Return Type: Notice that the type of length and anotherLength is Int?, not Int. This is crucial. The safe call operator always returns a nullable type, even if the member itself is non-nullable, because there’s always the possibility that the original variable was null.
  • Chaining: Safe calls can be chained together. This is incredibly powerful for navigating complex object graphs:

    “`kotlin
    data class Address(val street: String?, val city: String?)
    data class Person(val name: String, val address: Address?)

    val person: Person? = getPersonFromSomewhere()

    val city: String? = person?.address?.city
    “`

    In this example, city will be null if either person is null, person.address is null, or person.address.city is null. Any null along the chain will cause the entire expression to evaluate to null. This avoids deeply nested if statements.
    * Safe Calls with Functions: You can use safe calls with functions as well:

    “`kotlin
    val myString: String? = “Hello”
    val upperCase: String? = myString?.toUpperCase() // upperCase will be “HELLO”

    val anotherString: String? = null
    val anotherUpperCase: String? = anotherString?.toUpperCase() // anotherUpperCase will be null
    * **Safe Calls on the Left-Hand Side of Assignment:** Safe calls can be used on the left-hand side of an assignment *only* in conjunction with the Elvis operator (explained later) or `let`, `run`, `with`, `apply`, or `also` scope functions (also explained later):kotlin
    val person: Person? = getPerson()
    person?.address?.city = “New York” // This line will compile, but the assignment will only happen if person and person.address are not null.
    “`
    Without the Elvis operator or a scope function, the compiler would flag the assignment as potentially unsafe, as the value to be assigned could end up being discarded.
    * Member vs. Extension Functions: Safe calls work with both member functions (defined within the class) and extension functions (defined outside the class).

4. The Not-Null Assertion Operator (!!)

The not-null assertion operator (!!) is a way to tell the compiler, “I know this variable is not null, even though its type is nullable. Treat it as non-nullable.” It’s essentially a way to bypass Kotlin’s null safety checks.

Syntax:

kotlin
nullableVariable!!

Semantics:

  1. Assertion: The !! operator asserts that nullableVariable is not null.
  2. Return Value:
    • If nullableVariable is not null, the operator returns the value of nullableVariable, and the type of the result is the non-nullable version of the variable’s type.
    • If nullableVariable is null, the operator throws a KotlinNullPointerException (a subclass of NullPointerException).

Example:

“`kotlin
val myString: String? = “Hello”
val length: Int = myString!!.length // length will be 5 (Int)

val anotherString: String? = null
val anotherLength: Int = anotherString!!.length // Throws KotlinNullPointerException
“`

Key Points:

  • Non-Nullable Return Type: The !! operator always returns a non-nullable type. This is the key difference from the safe call operator.
  • Runtime Exception: If your assertion is incorrect (the variable is null), you’ll get a KotlinNullPointerException at runtime. This is exactly what Kotlin’s null safety system is designed to prevent, so use !! with extreme caution.
  • Loss of Safety: Using !! essentially removes the safety net that Kotlin provides. You’re taking full responsibility for ensuring that the variable is not null.
  • Naming: The double exclamation points are visually striking. This is intentional. It is meant to act as a red flag, signifying code that needs careful review.

When (and When NOT) to Use !!:

The !! operator should be used sparingly. It’s generally considered bad practice to use it liberally, as it defeats the purpose of Kotlin’s null safety. Here are some situations where it might be justified:

  • Interoperability with Java: When you’re calling Java code that doesn’t have nullability annotations, you might receive a value that Kotlin considers nullable, but you know (based on the Java code’s logic or documentation) that it will never be null. In this case, !! can be used to avoid unnecessary null checks in your Kotlin code. However, even in this case, it’s often better to wrap the Java call in a Kotlin function that handles the nullability explicitly, perhaps using a safe call and returning a default value or throwing a more specific exception.

  • Testing: In unit tests, you might use !! to assert that a value is not null after a specific operation. This can be a convenient way to make your tests more concise. However, consider using assertion libraries that provide more descriptive error messages.

  • Performance (Extremely Rare): In extremely performance-critical code, where you’ve thoroughly profiled your application and determined that the overhead of null checks is a significant bottleneck, and you’re absolutely certain that a variable will never be null, you might consider using !!. This should be a last resort, and only after careful consideration and measurement. The performance gains are usually negligible, and the risk of introducing a NullPointerException is high.

  • Late Initialization: If you are absolutely certain a variable will be initialized before it’s used, but you can’t initialize it in the constructor, you might temporarily use !!. However, lateinit (discussed later) is almost always a better solution for this.

In general, avoid !! whenever possible. There are almost always safer and more idiomatic ways to handle nullable values in Kotlin.

5. The Elvis Operator (?:)

The Elvis operator (?:) is a concise way to provide a default value when dealing with a nullable expression. It’s often used in conjunction with the safe call operator.

Syntax:

kotlin
nullableExpression ?: defaultValue

Semantics:

  1. Evaluate Left-Hand Side: The operator first evaluates the expression on the left-hand side (nullableExpression).
  2. Conditional Return:
    • If nullableExpression is not null, the operator returns the result of nullableExpression.
    • If nullableExpression is null, the operator returns the value on the right-hand side (defaultValue).

Example:

“`kotlin
val myString: String? = null
val length: Int = myString?.length ?: 0 // length will be 0

val anotherString: String? = “Hello”
val anotherLength: Int = anotherString?.length ?: 0 // anotherLength will be 5
“`

Key Points:

  • Default Value: The Elvis operator provides a simple way to handle the null case by providing a fallback value.
  • Type Compatibility: The type of defaultValue must be compatible with the non-nullable version of the type of nullableExpression. In the example above, 0 (an Int) is compatible with the non-nullable version of Int?.
  • Short-Circuiting: The right-hand side (defaultValue) is only evaluated if the left-hand side is null. This can be important if the default value is expensive to compute.
  • Chaining with Safe Calls: The Elvis operator is commonly used with safe calls to provide default values in chained expressions:

    kotlin
    val city: String = person?.address?.city ?: "Unknown"

    * Returning Early or Throwing Exceptions: You can use the Elvis operator with return or throw to exit a function early or throw a custom exception if a value is null:

    “`kotlin
    fun processString(str: String?) {
    val nonNullString: String = str ?: return // Exit the function if str is null
    // … process nonNullString
    }

    fun getName(person: Person?): String {
    return person?.name ?: throw IllegalArgumentException(“Person must not be null”)
    }
    ``
    This is much cleaner and safer than using
    !!`.

6. Safe Casts (as?)

The as? operator is a safe way to perform type casting in Kotlin. It attempts to cast a value to a specified type, and if the cast is successful, it returns the cast value. If the cast is not successful (the value is not of the specified type), it returns null.

Syntax:

kotlin
value as? TargetType

Semantics:

  1. Attempt Cast: The operator tries to cast value to TargetType.
  2. Conditional Return:
    • If the cast is successful, the operator returns the cast value (of type TargetType?).
    • If the cast is unsuccessful, the operator returns null.

Example:

“`kotlin
val anyValue: Any = “Hello”
val stringValue: String? = anyValue as? String // stringValue will be “Hello” (String?)

val anotherValue: Any = 123
val anotherStringValue: String? = anotherValue as? String // anotherStringValue will be null
“`

Key Points:

  • Nullable Return Type: The safe cast operator always returns a nullable type (TargetType?), even if TargetType is non-nullable. This is because the cast might fail.
  • Combining with Elvis Operator: You can combine the safe cast operator with the Elvis operator to provide a default value or throw an exception if the cast fails:

    “`kotlin
    val stringValue: String = (anyValue as? String) ?: “Default Value”
    val stringValue2: String = (anyValue as? String) ?: throw IllegalArgumentException(“Invalid type”)

    ``
    * **Avoiding ClassCastException:** The regular cast operator (
    as) will throw aClassCastExceptionat runtime if the cast is not possible. The safe cast operator (as?) avoids this exception by returningnull`.

7. Scope Functions: let, run, with, apply, and also

Kotlin’s standard library provides a set of functions called “scope functions” that can be used to execute a block of code in the context of an object. These functions are particularly useful for working with nullable types, often in conjunction with the safe call operator.

  • let:

    • Purpose: Executes a block of code with the object as the receiver (passed as it) and returns the result of the block.
    • Use with Nullables: Commonly used with the safe call operator to execute code only if a value is not null:

      kotlin
      val myString: String? = "Hello"
      myString?.let {
      // This block executes only if myString is not null
      println("Length: ${it.length}") // 'it' refers to the non-null myString
      }

      * Return Value: Returns the result of the lambda expression.

  • run:

    • Purpose: Similar to let, but executes the block of code with the object as the receiver (accessed using this) and returns the result of the block.
    • Use with Nullables: Can also be used with safe calls, but more often used when you want to perform multiple operations on an object and return a result:

      kotlin
      val result = myString?.run {
      // this refers to the non-null myString.
      val upper = this.toUpperCase()
      val lower = this.toLowerCase()
      "$upper - $lower"
      }

      * Return Value: Returns the result of the lambda expression.
      * Non-extension form: run also exists in a non-extension form that does not take a receiver, it just runs a block and returns a value, similar to an immediately invoked function expression in JavaScript.

  • with:

    • Purpose: Executes a block of code with the object as the receiver (accessed using this) and returns the result of the block. Unlike let and run, with is not an extension function; it takes the object as an argument.
    • Use with Nullables: Less commonly used with safe calls directly, but can be useful for working with nullable objects within a block:

      “`kotlin
      val person: Person? = getPerson()
      with(person) {
      if(this != null){
      println(“Name: ${this.name}”) // ‘this’ refers to the person object
      }
      }

      ``
      However,
      person?.let { … }` is often preferred for its conciseness.
      * Return Value: Returns the result of the lambda expression.

  • apply:

    • Purpose: Executes a block of code with the object as the receiver (accessed using this) and returns the object itself. Primarily used for object configuration.
    • Use with Nullables: Can be used with safe calls to configure an object only if it’s not null:

      kotlin
      val person: Person? = getPerson()
      person?.apply {
      // This block executes only if person is not null
      address = Address("123 Main St", "Anytown") // 'this' refers to the person object
      }

      * Return Value: Returns the receiver object (in this case, person or null if the safe call failed).

  • also:

    • Purpose: Executes a block of code with the object as the receiver (passed as it) and returns the object itself. Used for performing side effects.
    • Use with Nullables: Can be used with safe calls to perform actions on an object only if it’s not null:

      kotlin
      val myString: String? = "Hello"
      myString?.also {
      // This block executes only if myString is not null
      println("Original string: $it") // 'it' refers to the non-null myString
      }

      * Return Value: Returns the receiver object.

Choosing the Right Scope Function:

The choice of which scope function to use depends on the specific situation:

  • let: Use for performing operations on a non-null value and potentially returning a different value.
  • run: Use for performing multiple operations on a non-null value and returning a different value, especially when you need to use this to refer to the receiver.
  • with: Use for performing operations on an object (potentially nullable, but handled with an explicit null check inside the block) and returning a different value.
  • apply: Use for configuring an object (modifying its properties) and returning the object itself.
  • also: Use for performing side effects on an object and returning the object itself.

The scope functions, combined with the safe call operator, provide a powerful and expressive way to handle nullable values in Kotlin, avoiding verbose null checks and reducing the risk of NullPointerExceptions.

8. lateinit Variables

The lateinit modifier is used to declare a non-null property that is not initialized at the time of declaration. It’s a promise to the compiler that you will initialize the property before it’s accessed. If you access a lateinit property before it’s initialized, you’ll get an UninitializedPropertyAccessException at runtime.

Syntax:

kotlin
lateinit var myProperty: MyType

Key Points:

  • Non-Null Type: lateinit properties must have a non-nullable type.
  • var Only: lateinit can only be used with var properties (mutable properties), not val properties (read-only properties).
  • No Primitive Types: lateinit cannot be used with primitive types (e.g., Int, Boolean, Double).
  • Checking Initialization: You can check if a lateinit property has been initialized using ::myProperty.isInitialized:

    kotlin
    if (::myProperty.isInitialized) {
    // Use myProperty
    }

    * When to Use: Use lateinit when you’re certain a property will be initialized before it is accessed, but you can’t do it at declaration time. Common scenarios include:
    * Dependency injection frameworks.
    * Android Activity lifecycle (e.g. initializing views in onCreate).
    * Setup methods in test classes.

lateinit vs. Nullable Types:

lateinit is an alternative to using a nullable type with an initial value of null. The main difference is that lateinit avoids the need for null checks once the property is initialized. However, it also shifts the responsibility for initialization to the developer, and an uninitialized access will result in a runtime exception.

Example (Android):

“`kotlin
class MyActivity : AppCompatActivity() {

lateinit var myTextView: TextView

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_my)

    myTextView = findViewById(R.id.myTextView) // Initialization happens here

    myTextView.text = "Hello from lateinit!"
}

}
``
In this Android example, it's common to initialize views in the
onCreatemethod.lateinitallows us to declaremyTextView` as non-nullable, avoiding null checks later, while still allowing us to initialize it after the layout is inflated.

9. Null Safety and Collections

Kotlin’s collections (lists, sets, maps) also have built-in support for null safety. You can have collections of nullable types, and you can also have nullable collections.

  • Collection of Nullable Types:

    kotlin
    val list: List<String?> = listOf("Hello", null, "World")

    This list can contain String objects or null values. You’ll need to handle the potential null values when accessing elements of the list (e.g., using safe calls or the Elvis operator).

  • Nullable Collection:

    kotlin
    val list: List<String>? = null

    This variable can either hold a List<String> or be null. You’ll need to use the safe call operator to access the list itself:

    kotlin
    val size: Int? = list?.size // size will be null

    * Filtering Nulls: The filterNotNull() function can be used on a collection of nullable types to create a new collection containing only the non-null elements.

    kotlin
    val listWithNulls: List<String?> = listOf("a", null, "b", "c", null)
    val listWithoutNulls: List<String> = listWithNulls.filterNotNull() // Result: ["a", "b", "c"]

10. Best Practices and Style Guide

Here’s a summary of best practices for working with null safety in Kotlin:

  1. Prefer Non-Nullable Types: Use non-nullable types whenever possible. This makes your code more robust and easier to reason about.
  2. Use the Safe Call Operator (?.): This is the primary tool for safely accessing members of nullable variables.
  3. Use the Elvis Operator (?:): Provide default values concisely when dealing with nullable expressions.
  4. Avoid !!: The not-null assertion operator should be used sparingly, only when you’re absolutely certain that a variable is not null and there’s no better alternative.
  5. Use Scope Functions: let, run, with, apply, and also can make your code more concise and readable when working with nullable types.
  6. Consider lateinit for Deferred Initialization: If you need to delay initialization of a non-null property, use lateinit (but be aware of the potential for UninitializedPropertyAccessException).
  7. Use Safe Casts (as?): Avoid ClassCastExceptions by using safe casts when performing type conversions.
  8. Handle Nulls Explicitly: Don’t rely on implicit null handling. Make your code’s behavior in the presence of null values clear and predictable.
  9. Document Nullability: If a function can return null or accept null arguments, document this clearly in the function’s KDoc.
  10. Test for Null Cases: Write unit tests that specifically cover cases where values might be null.

11. Advanced Topics and Edge Cases

  • Smart Casts: Kotlin’s compiler is smart enough to track null checks within a scope. If you check that a nullable variable is not null using an if statement, the compiler will “smart cast” the variable to its non-nullable type within the if block:

    kotlin
    val myString: String? = getSomeString()
    if (myString != null) {
    // Inside this block, myString is smart-cast to String (non-nullable)
    val length = myString.length // No safe call needed here
    }

    Smart casts also work with when expressions and is checks. They do not work across function boundaries or with properties that might be modified by other threads.

  • Delegated Properties: Delegated properties can be used to customize how properties are accessed and modified. You can create custom delegates that handle nullability in specific ways.

  • Generics and Nullability: When working with generics, you need to be careful about nullability. By default, a type parameter T is considered non-nullable. To make it nullable, you need to use T?. You can also use upper bounds to restrict the type parameter to non-nullable types (e.g., T : Any).

  • Java Interoperability (Detailed): Kotlin’s null safety system interacts with Java code, but there are some nuances:

    • Platform Types: When you call Java code from Kotlin, the types returned from Java methods are treated as “platform types.” These types have unknown nullability (denoted as String! in error messages). Kotlin doesn’t enforce null checks on platform types, but it’s your responsibility to handle them correctly. You can either treat them as nullable (using safe calls) or assume they’re non-nullable (using !! – but be careful!).
    • Nullability Annotations: If the Java code uses nullability annotations (e.g., @Nullable, @NonNull from JSR-305, JetBrains annotations, or Android’s @Nullable/@NonNull), Kotlin will respect these annotations and treat the types accordingly. This is the best way to ensure seamless interoperability.
    • @JvmNullable and @JvmNonNul: From Kotlin 1.9 onwards, these annotations can be used to explicitly express nullability on your Kotlin code, so they can be visible to the Java callers.
    • Best Practice: When interacting with Java code, it’s best to:
      1. Use Java code with nullability annotations if possible.
      2. If annotations are not available, carefully examine the Java code and documentation to determine the intended nullability.
      3. Consider wrapping Java calls in Kotlin functions that handle nullability explicitly, providing a cleaner and safer API for the rest of your Kotlin code.
  • Kotlin/JS and Kotlin/Native: Kotlin’s null safety features are also available when compiling to JavaScript (Kotlin/JS) and native code (Kotlin/Native).

12. Comparison with Other Languages

  • Java: Java has optional types (java.util.Optional) that can be used to represent values that might be absent. However, Optional is not enforced by the compiler, and it’s still possible to have NullPointerExceptions. Kotlin’s null safety is built into the type system and enforced at compile time.

  • Swift: Swift also has a strong null safety system based on optional types (using ? to denote optionals). Swift’s optionals are similar to Kotlin’s nullable types, and Swift also has features like optional chaining (similar to Kotlin’s safe calls) and forced unwrapping (similar to Kotlin’s !!).

  • C#: C# 8.0 introduced nullable reference types, which are similar to Kotlin’s nullable types. C# also has a null-conditional operator (?.) and a null-coalescing operator (??, similar to Kotlin’s Elvis operator).

  • TypeScript: TypeScript, a superset of JavaScript, also has support for nullable types. You can use the union type string | null to indicate that a variable can be either a string or null. TypeScript also has optional chaining (?.) and nullish coalescing (??).

Kotlin’s null safety system is generally considered to be more concise and integrated into the language than Java’s Optional, and it provides a similar level of safety to Swift and C#’s nullable types.

Conclusion

Kotlin’s null safety, powered by nullable types, the safe call operator (?.), the not-null assertion operator (!!), and the Elvis operator (?:), is a powerful feature that significantly improves code reliability and reduces the risk of NullPointerExceptions. By understanding these operators and the related concepts (scope functions, lateinit, smart casts, etc.), you can write more robust, maintainable, and less error-prone Kotlin code. While !! offers an escape hatch, it should be used judiciously, with the vast majority of nullable handling done through safer mechanisms. The ? operator, combined with other null-safe features of Kotlin, allows for expressive and safe handling of potentially missing values, contributing significantly to Kotlin’s reputation for developer productivity and code quality.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top