Welcome to the lesson where Kotlin starts to feel like a real professional toolbox. You already know how to write functions, classes and lambdas. Now you will learn four techniques that working Android developers use every single day:
- Generics, which let one class or function work safely with many different types.
- Singleton and companion objects, which give you exactly one shared instance of something.
- Extension functions and properties, which let you add new abilities to types you did not write.
- Scope functions (
let,run,with,apply,also), which make setup and null-handling code short and readable.
Picture a school's lost-and-found box. A box does not care whether it holds a water bottle, a calculator or a jacket. It just holds something, and when you take the item out you get back the same kind of thing you put in. That is a generic container. Kotlin's List<String> and List<Int> are the same idea. You have already been using generics without knowing it.
By the end of this lesson you will be able to:
- Write your own generic classes and generic functions with type parameters such as
T. - Add a constraint to a type parameter so it only accepts suitable types.
- Create a singleton with the
objectkeyword. - Use a companion object for shared constants and factory functions.
- Write extension functions and extension properties, and explain their limits.
- Choose the right scope function for the job.
- Combine all of these in a small quiz engine.
How this fits the bigger picture: This lesson matches the Generics, objects, and extensions pathway from Unit 3 of Google's official Android Basics with Compose course. That pathway builds a quiz-style app step by step. We build our own original quiz engine and add more practice. The official link is in the resources at the end.
Why this matters for Android
ViewModel,StateFlow,ListandResultare all generic. You will read generic signatures likeMutableStateFlow<UiState>for the rest of this course.- Libraries add extension functions to Android types. Writing
modifier.padding(16.dp)is an extension function call, and16.dpis an extension property onInt. - Many Android databases, settings holders and loggers are singletons.
- Scope functions such as
applyappear in almost every piece of setup code you will read.
1. Classes, properties and methods (lesson 11)
You should know how to declare class Student(val name: String), create an instance and call its functions. You should also recall data class, enum class, interface and override.
2. Function types and lambdas (lesson 12)
Scope functions take a lambda as their last parameter, so trailing lambda syntax and it should feel familiar. If { it * 2 } looks strange, revisit lesson 12 first.
3. Nullability (lesson 10)
You should be comfortable with String?, the safe call ?. and the fact that Kotlin refuses to let you call a method on a possibly-null value without checking.
4. Types and inference (lesson 07)
Kotlin usually works out types for you (val age = 24 is an Int). In this lesson you will see the compiler infer types in more places, including inside angle brackets.
The problem generics solve
Suppose you need a box that holds one item. Without generics you might write one class per type:
class IntBox(val item: Int)
class StringBox(val item: String)
class BooleanBox(val item: Boolean)
// ... and one for every other type, forever
That is repetition, and repetition breeds bugs. Another idea is to use the type Any (Kotlin's "anything" type). But then, when you take the item out, you get an Any, and you must guess what it really is. Guessing wrongly crashes the app.
Generics give you the third option: write the class once, leaving a blank for the type. The blank is called a type parameter, and by convention it is a single capital letter:
| Letter | Usually means |
|---|---|
T |
Type (the general-purpose choice) |
E |
Element (inside collections) |
K, V |
Key and Value (in maps) |
R |
Result |
class Box<T>(val item: T)
|
+-- a blank for the type, filled in when you use it
Box<Int>(5) the blank becomes Int
Box<String>("shoes") the blank becomes String
Kotlin fills the blank in for you when it can: Box(5) is automatically a Box<Int>.
Constraints: limiting what the blank may be
Sometimes "any type at all" is too loose. A function that finds the bigger of two things must be able to compare them, and not every type can be compared. You can put a limit on the blank with a colon:
<T : Comparable<T>> T must be something that can be compared with itself
<T : Priced> T must be something that implements the Priced interface
This is called an upper bound or constraint. It is a promise to the compiler: "whatever T turns out to be, it has at least these abilities". In return, inside the function you may use those abilities.
Analogy: A generic container is a lunch box. A constraint is a label on it: "hot food only". Now you know the item will survive a microwave, so you can safely heat the box without checking what is inside.
A short word on in and out
You may see List<out E> or Comparable<in T> in documentation. These markers control whether, for example, a List<String> may be used where a List<Any> is expected. Reading a list of strings as a list of "anythings" is safe, which is why List is declared with out. You do not need to write these yourself yet. Just recognise them when you meet them.
Example 1: A generic class
Input (code):
class Box<T>(val item: T) {
fun describe(): String = "Box holds $item"
}
fun main() {
val numberBox = Box(5)
val wordBox = Box("shoes")
val flagBox = Box(true)
println(numberBox.describe())
println(wordBox.describe())
println(flagBox.describe())
}
Expected output:
Box holds 5
Box holds shoes
Box holds true
Explanation: One class definition, three different types. Kotlin infers Box<Int>, Box<String> and Box<Boolean>. Because the compiler knows numberBox.item is an Int, writing numberBox.item + 1 works, and writing wordBox.item + 1 is a compile error.
Example 2: A generic function
Type parameters for functions go before the function name.
Input (code):
fun <T> repeatItem(item: T, times: Int): String {
return List(times) { item }.joinToString(", ")
}
fun main() {
println(repeatItem("Hi", 3))
println(repeatItem(7, 2))
}
Expected output:
Hi, Hi, Hi
7, 7
Explanation: List(times) { item } builds a list that contains item the given number of times (lists are covered fully in lesson 14), and joinToString(", ") glues the elements into one string. The function never needs to know what T is.
Example 3: A constraint with Comparable
Input (code):
fun <T : Comparable<T>> biggest(a: T, b: T): T {
return if (a > b) a else b
}
fun main() {
println(biggest(3, 9))
println(biggest("apple", "mango"))
println(biggest(2.5, 1.5))
}
Expected output:
9
mango
2.5
Explanation: The > operator needs compareTo, which every Comparable type has. Int, String and Double are all comparable, so all three calls compile. Strings compare alphabetically, and "mango" sorts after "apple". If you remove the constraint and write plain <T>, the line a > b becomes a compile error, because Kotlin cannot promise that an arbitrary T can be compared.
Example 4: A constraint with your own interface
Input (code):
interface Priced {
val price: Int
}
data class Shirt(override val price: Int) : Priced
data class Shoes(override val price: Int) : Priced
fun <T : Priced> totalPrice(first: T, second: T): Int {
return first.price + second.price
}
fun main() {
println(totalPrice(Shirt(3000), Shirt(4500)))
println(totalPrice(Shoes(12000), Shoes(8000)))
}
Expected output:
7500
20000
Explanation: Inside totalPrice you can read .price because the constraint guarantees it exists. Both arguments must be the same T, so totalPrice(Shirt(1), Shoes(2)) would not compile. If you wanted to allow mixing, you would write the parameters as Priced instead of T.
Singletons with object
Normally you create many instances of a class. Sometimes you want exactly one: a single settings holder, a single logger, a single scoreboard. That pattern is called a singleton, and Kotlin builds it into the language. Replace class with object and you are done:
object AppConfig {
const val APP_NAME = "Techbase Quiz"
var launchCount = 0
fun recordLaunch() {
launchCount++
}
}
There is no constructor and you never write AppConfig(). You use the name directly, and Kotlin creates the one and only instance the first time it is needed.
Companion objects
A companion object is an object that lives inside a class and belongs to the class itself rather than to any one instance. If you have used static in Java, a companion object fills that role. It is the right home for constants and factory functions (functions that create instances).
Instance members Companion members
(one per object) (one per class)
--------------------- -----------------------
student.name Student.create("Ada")
student.greet() Student.count
Which one do I use?
| You want | Use |
|---|---|
| One global thing with no instances at all | object |
| A constant or helper that belongs to a class | companion object |
| A constant known at compile time (number or string) | const val inside an object or companion |
| Many instances | A normal class |
Example 5: A singleton in action
Input (code):
object AppConfig {
const val APP_NAME = "Techbase Quiz"
var launchCount = 0
fun recordLaunch() {
launchCount++
}
}
fun main() {
println(AppConfig.APP_NAME)
AppConfig.recordLaunch()
AppConfig.recordLaunch()
println("Launches: ${AppConfig.launchCount}")
}
Expected output:
Techbase Quiz
Launches: 2
Explanation: Both calls to recordLaunch() changed the same object, which is why the count reached 2. const val marks a value fixed at compile time. It is only allowed on String and the basic number and boolean types, and only at the top level or inside an object or companion object.
Example 6: A companion object and a factory function
Input (code):
class Student private constructor(val name: String) {
companion object {
const val SCHOOL = "Techbase Academy"
var created = 0
fun create(name: String): Student {
created++
return Student(name)
}
}
fun introduce() {
println("$name attends $SCHOOL")
}
}
fun main() {
val ada = Student.create("Ada")
val bola = Student.create("Bola")
ada.introduce()
bola.introduce()
println("Students created: ${Student.created}")
}
Expected output:
Ada attends Techbase Academy
Bola attends Techbase Academy
Students created: 2
Explanation: The constructor is private, so outside code cannot write Student("Ada"). The only way in is Student.create(...), which also keeps count. A companion object can call the private constructor because it is inside the class. This is a common way to force every instance to be created in a controlled way.
Adding abilities to types you do not own
You cannot open Kotlin's String class and add a function to it. But you can write an extension function, which looks like a member of the class when you call it. The recipe is to put the type you are extending in front of the function name, followed by a dot:
fun String . shout (): String = ...
| |
| +-- the new function's name
+-- the "receiver type": the thing being extended
Inside the extension, the keyword this means the value the function was called on (and you can often leave this out).
An extension property is the same idea for properties. It cannot store data (there is no hidden field), so it must have a getter that computes its value.
The rules and limits
- An extension cannot access private members of the class it extends. It is only a tidy way of calling public things.
- If the class already has a member function with the same name and parameters, the member wins. Extensions never override.
- Extensions are resolved by the declared type at compile time, not changed by inheritance at run time.
- They are not magic:
"hi".shout()compiles to an ordinary function callshout("hi").
Analogy: Giving a friend a phone case with a card holder does not change the phone. It just lets you do something extra with it that feels built in.
Example 7: Extension functions
Input (code):
fun String.shout(): String = this.uppercase() + "!"
fun Int.isEven(): Boolean = this % 2 == 0
fun Int.toNaira(): String = "₦$this"
fun main() {
println("hello".shout())
println(4.isEven())
println(7.isEven())
println(1500.toNaira())
}
Expected output:
HELLO!
true
false
₦1500
Explanation: Each function is declared outside any class, yet is called like a built-in. In toNaira, this is the Int the function was called on (1500), so the string template builds ₦1500.
Example 8: Extension properties
Input (code):
val String.vowelCount: Int
get() = count { it in "aeiouAEIOU" }
val Int.squared: Int
get() = this * this
fun main() {
println("Ibadan".vowelCount)
println("Rhythm".vowelCount)
println(7.squared)
}
Expected output:
3
0
49
Explanation: "Ibadan" contains the vowels I, a and a, so the count is 3. "Rhythm" has none among aeiou, so 0. The count { ... } call runs the lambda on each character and counts the ones for which it returns true. Notice that extension properties use get() instead of = value, because they cannot hold a value of their own.
Example 9: Extending a generic type
Extensions and generics combine well. This one works on a list of any element type:
Input (code):
fun <T> List<T>.secondOrNull(): T? {
return if (this.size >= 2) this[1] else null
}
fun main() {
println(listOf("a", "b", "c").secondOrNull())
println(listOf(10).secondOrNull())
}
Expected output:
b
null
Explanation: The <T> before List<T> declares the type parameter that the extension uses. The return type is T? because there might be no second element. The built-in function getOrNull(1) does the same job, but writing it yourself shows the pieces working together.
Five helpers, one idea
The Kotlin standard library has five small functions that run a block of code "in the scope of" an object: let, run, with, apply and also. They do the same basic job, but they differ in two ways:
- How the object is referred to inside the block: as
it, or asthis(which means you can often just write property names). - What the whole call returns: the lambda's last value, or the object itself.
| Function | Object inside is | Returns | Typical use |
|---|---|---|---|
let |
it |
the lambda result | run code only if a value is not null; transform a value |
run |
this |
the lambda result | compute something using the object's members |
with(obj) |
this |
the lambda result | several calls on one object (not an extension, you pass the object in) |
apply |
this |
the object itself | configure an object right after creating it |
also |
it |
the object itself | a side effect such as logging, without changing the chain |
A memory trick: apply and also hand the object back (apply it, then give it back). let, run and with hand back whatever the block produced. And it goes with let and also, while this goes with run, with and apply. Keep the table open until it becomes habit. Nobody memorises it on the first day.
Do not overdo it. Scope functions are optional. If a plain
valand two lines are clearer, use those. Nesting several scope functions inside each other usually makes code harder to read.
Example 10: let for null handling
Input (code):
fun main() {
val name: String? = "tunde"
val nothing: String? = null
name?.let { println("Hello ${it.uppercase()}") }
nothing?.let { println("This never prints") }
println("Done")
}
Expected output:
Hello TUNDE
Done
Explanation: With the safe call ?.let, the block runs only when the value is not null, and inside it it is a non-null String. The second let is skipped completely. This replaces a lot of if (x != null) blocks.
Example 11: apply to configure an object
Input (code):
class Profile {
var name = ""
var city = ""
var age = 0
}
fun main() {
val profile = Profile().apply {
name = "Ada"
city = "Ibadan"
age = 20
}
println("${profile.name}, ${profile.city}, ${profile.age}")
}
Expected output:
Ada, Ibadan, 20
Explanation: Inside apply, this is the new Profile, so name = "Ada" really means this.name = "Ada". apply then returns the profile itself, which is why it can be assigned straight to val profile. Without apply you would have to write profile.name = ... three times.
Example 12: also, run and with
Input (code):
class Profile {
var name = ""
var city = ""
var age = 0
}
fun main() {
val sum = (2 + 3).also { println("Sum is $it") }
val profile = Profile().apply {
name = "Ada"
city = "Ibadan"
age = 20
}
val summary = profile.run { "$name lives in $city" }
val line = with(profile) { "$name is $age" }
println(sum)
println(summary)
println(line)
}
Expected output:
Sum is 5
5
Ada lives in Ibadan
Ada is 20
Explanation: also ran its side effect (the print), then handed back the original 5, so sum is 5. run and with both return the last expression in the block, here a string. The difference between them is only how you write it: profile.run { ... } versus with(profile) { ... }.
Now we combine everything into one small program, a tiny version of the quiz idea from the official pathway. It has a generic Question<T> (answers can be text, numbers or true/false), an enum for difficulty, a singleton scoreboard and an extension function that checks answers.
Step 1: The data
enum class Difficulty { EASY, MEDIUM, HARD }
data class Question<T>(
val text: String,
val answer: T,
val difficulty: Difficulty
)
Question<String> holds a text answer, Question<Int> a number, Question<Boolean> a yes/no. One class covers all three, and the compiler checks the type of every answer you supply.
Step 2: A singleton scoreboard
object Scoreboard {
var points = 0
private set
fun award(difficulty: Difficulty) {
points += when (difficulty) {
Difficulty.EASY -> 1
Difficulty.MEDIUM -> 2
Difficulty.HARD -> 3
}
}
}
private set means any code can read points, but only the object itself can change it. That protects the score from accidental edits. The when over an enum must cover every case, and because it is used as an expression, Kotlin checks that you did.
Step 3: An extension on a generic type
fun <T> Question<T>.check(given: T): Boolean {
val correct = given == answer
if (correct) Scoreboard.award(difficulty)
val result = if (correct) "correct" else "wrong, answer was $answer"
println("$text -> $result")
return correct
}
check is added to every Question<T>. It compares the given answer to the real one, awards points on success and prints a result. Inside the function we can read answer, text and difficulty directly, because this is the question.
Step 4: Run it
fun main() {
val capital = Question("Capital of Nigeria?", "Abuja", Difficulty.EASY)
val states = Question("How many states does Nigeria have, not counting the FCT?", 36, Difficulty.MEDIUM)
val typed = Question("Is Kotlin statically typed?", true, Difficulty.MEDIUM)
capital.check("Abuja")
states.check(35)
typed.check(true)
println("Points: ${Scoreboard.points}")
}
Expected output:
Capital of Nigeria? -> correct
How many states does Nigeria have, not counting the FCT? -> wrong, answer was 36
Is Kotlin statically typed? -> correct
Points: 3
Working it out: The first answer is right and is worth 1 point. The second is wrong (35, not 36), so no points. The third is right and MEDIUM is worth 2. The total is 1 + 2 = 3. Try states.check("36"): it will not compile, because states is a Question<Int> and a String is not an Int. That compile error is generics protecting you.
Exercise 1: A generic wrapper
Objective: Write a generic function and a constrained generic function.
Steps:
- Write
fun <T> wrap(item: T): Stringthat returns the item inside square brackets. - Print
wrap(5),wrap("a")andwrap(2.5). - Write
fun <T : Comparable<T>> smallest(a: T, b: T): Tand printsmallest(8, 3)andsmallest("zebra", "ant").
fun <T> wrap(item: T): String = "[$item]"
fun <T : Comparable<T>> smallest(a: T, b: T): T {
return if (a < b) a else b
}
fun main() {
println(wrap(5))
println(wrap("a"))
println(wrap(2.5))
println(smallest(8, 3))
println(smallest("zebra", "ant"))
}
Expected output:
[5]
[a]
[2.5]
3
ant
"ant" comes before "zebra" alphabetically, so it is the smaller string.
Exercise 2: A ticket machine
Create a singleton TicketMachine with a private set property lastTicket starting at 0 and a function next(): Int that increases it by one and returns it. Then create a class Bus with a companion object holding const val OPERATOR = "Ibadan Express". Print the operator, then take three tickets and print each.
object TicketMachine {
var lastTicket = 0
private set
fun next(): Int {
lastTicket++
return lastTicket
}
}
class Bus {
companion object {
const val OPERATOR = "Ibadan Express"
}
}
fun main() {
println(Bus.OPERATOR)
println("Ticket ${TicketMachine.next()}")
println("Ticket ${TicketMachine.next()}")
println("Ticket ${TicketMachine.next()}")
}
Expected output:
Ibadan Express
Ticket 1
Ticket 2
Ticket 3
Because TicketMachine is a singleton, the counter is shared by every caller and never repeats a number.
Exercise 3: Extensions and scope functions together
- Write an extension function
String.isPalindrome(): Booleanthat ignores letter case. Test"Level","Kayak"and"Ibadan". - Write an extension property
val Int.cubed: Intand print3.cubed. - Create a class
Settingswithvar theme = "light"andvar fontSize = 14. Build one usingapplyso the theme is"dark"and the font size is18, then print it usingwith.
fun String.isPalindrome(): Boolean {
val cleaned = this.lowercase()
return cleaned == cleaned.reversed()
}
val Int.cubed: Int
get() = this * this * this
class Settings {
var theme = "light"
var fontSize = 14
}
fun main() {
println("Level".isPalindrome())
println("Kayak".isPalindrome())
println("Ibadan".isPalindrome())
println(3.cubed)
val settings = Settings().apply {
theme = "dark"
fontSize = 18
}
with(settings) {
println("Theme: $theme, size: $fontSize")
}
}
Expected output:
true
true
false
27
Theme: dark, size: 18
"Level" becomes "level", which reads the same backwards. "ibadan" reversed is "nadabi", which is different.
Write an extension function fun Double.toNairaString(): String that returns "₦" followed by the number. Use ?.let on a nullable Double? price so that the converted text prints only when the price is not null. Test it once with a value and once with null.
Mistake #1: Using a generic type without a constraint, then calling a method on it. The wrong code: fun <T> bigger(a: T, b: T) = if (a > b) a else b. The problem: Kotlin cannot promise that T supports >. The fix: add the bound, <T : Comparable<T>>.
Mistake #2: Using Any when you meant a generic. The wrong code: class Box(val item: Any). The problem: when you read item back it is just Any, and wrong guesses crash at run time. The fix: class Box<T>(val item: T) keeps the exact type.
Mistake #3: Trying to create an instance of an object. The wrong code: val c = AppConfig(). The problem: an object has no constructor, because it already is the single instance. The fix: use it by name: AppConfig.recordLaunch().
Mistake #4: Forgetting that a companion lives on the class. The wrong code: val s = Student("x"); s.created. The problem: companion members belong to the class, not to instances. The fix: Student.created.
Mistake #5: const val with a non-constant value. The wrong code: const val NOW = System.currentTimeMillis(). The problem: const values must be known when the code is compiled. The fix: use plain val, which can be computed at run time.
Mistake #6: Expecting an extension to reach private members. The wrong code: an extension that reads a private val of the class. The fix: extensions only see what any outside code sees. If you need the data, expose it properly in the class.
Mistake #7: Expecting an extension to replace a member. The problem: if String already has a function called shout() with the same parameters (it does not, but imagine), your extension would never be called, since members always win. The fix: choose a different name.
Mistake #8: Mixing up apply and let. The wrong code: val p = Profile().let { name = "Ada" } where name is a property of Profile. The problem: let gives you it, not this, so the bare name is unresolved. And let returns the lambda result, so p would not be a Profile anyway. The fix: use apply for configuring, or write it.name = "Ada" if you really want let.
Mistake #9: Giving an extension property a stored value. The wrong code: val Int.doubled: Int = this * 2. The problem: extension properties cannot have backing fields or initialisers. The fix: val Int.doubled: Int get() = this * 2.
Answer each question in your head (or on paper), then reveal the answer.
Question 1. What does the T in class Box<T> stand for, and when is it filled in?
T is a type parameter, a blank for a type. It is filled in when you create or use the class, for example Box<String>("x"), or automatically by type inference as in Box(5).
Question 2. Why does fun <T> bigger(a: T, b: T) = if (a > b) a else b not compile, and how do you fix it?
Because an unconstrained T might not support comparison. Add an upper bound: fun <T : Comparable<T>> bigger(a: T, b: T): T.
Question 3. What is the difference between an object and a companion object?
An object is a standalone singleton with one instance. A companion object is declared inside a class and holds members (constants, factory functions) that belong to the class itself rather than to its instances.
Question 4. Which scope function is best for configuring a new object, and what does it return?
apply. Inside the block the object is this, so you can set properties by name, and apply returns the object itself.
Question 5. Can an extension function read a private property of the class it extends? What if the class already has a member with the same name?
No, extensions can only use public members. And if the class has a member with the same signature, the member always wins over the extension.
Go deeper: official resources
- Android Basics with Compose, Unit 3, Pathway 1: Generics, objects and extensions: the official pathway this lesson is based on.
- Kotlin docs: Generics: type parameters, bounds and variance in full.
- Kotlin docs: Object declarations and expressions: singletons and companion objects.
- Kotlin docs: Extensions: everything about extension functions and properties.
- Kotlin docs: Scope functions: a detailed comparison of
let,run,with,applyandalso.
Project goal: Build a small shop toolkit for a market stall, using a constrained generic class, a singleton for shop settings, extension functions and scope functions.
Requirements:
- Declare
interface Priced { val price: Int }anddata class Item(val name: String, override val price: Int) : Priced. - Write a generic class
Crate<T : Priced>(val contents: T, val quantity: Int)with a functiontotal(): Intthat returns the item price times the quantity. - Write an extension function
fun Int.toNaira(): Stringreturning the number with a₦in front. - Create
object ShopSettingswithconst val SHOP_NAME = "Mama Tola Stores"andvar discountPercent = 10. - Write two extension functions on
Crate<T>(for anyT : Priced):summary()returning text like2 x ₦45000 = ₦90000, anddiscountedTotal()which subtractsdiscountPercentpercent from the total. - In
main, print a welcome line, then summaries for 2 bags of rice at 45000 and 1 bag of beans at 38000. - Print the discounted rice total, change the discount to 20 using
apply, and print it again. - Use
?.letwith a nullable coupon code to print a message only when the code exists. Test with a real code and withnull.
Stretch goal: Add a companion object to Item with a factory function fun free(name: String): Item that creates an item priced at 0, and print a Crate of three free samples.
interface Priced {
val price: Int
}
data class Item(val name: String, override val price: Int) : Priced {
companion object {
fun free(name: String): Item = Item(name, 0)
}
}
class Crate<T : Priced>(val contents: T, val quantity: Int) {
fun total(): Int = contents.price * quantity
}
fun Int.toNaira(): String = "₦$this"
object ShopSettings {
const val SHOP_NAME = "Mama Tola Stores"
var discountPercent = 10
}
fun <T : Priced> Crate<T>.summary(): String {
return "$quantity x ${contents.price.toNaira()} = ${total().toNaira()}"
}
fun <T : Priced> Crate<T>.discountedTotal(): Int {
return total() - total() * ShopSettings.discountPercent / 100
}
fun main() {
println("Welcome to ${ShopSettings.SHOP_NAME}")
val rice = Crate(Item("Rice bag", 45000), 2)
val beans = Crate(Item("Beans bag", 38000), 1)
println(rice.summary())
println(beans.summary())
println("Rice after ${ShopSettings.discountPercent}% off: ${rice.discountedTotal().toNaira()}")
ShopSettings.apply { discountPercent = 20 }
println("Rice after ${ShopSettings.discountPercent}% off: ${rice.discountedTotal().toNaira()}")
val coupon: String? = "WELCOME"
val missing: String? = null
coupon?.let { println("Coupon $it applied") }
missing?.let { println("This never prints") }
val samples = Crate(Item.free("Pepper sample"), 3)
println(samples.summary())
}
Expected output:
Welcome to Mama Tola Stores
2 x ₦45000 = ₦90000
1 x ₦38000 = ₦38000
Rice after 10% off: ₦81000
Rice after 20% off: ₦72000
Coupon WELCOME applied
3 x ₦0 = ₦0
How it works: The rice total is 45000 * 2 = 90000. A 10 percent discount is 90000 * 10 / 100 = 9000, leaving 81000. After the discount changes to 20, the reduction is 18000, leaving 72000. The missing?.let line prints nothing because the value is null. The Crate class only accepts types that implement Priced, so Crate("hello", 2) would be rejected by the compiler.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 13 project”.
On github.com click New repository, name it android-lesson-13-generics-objects-and-extensions, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 13 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Generics
- Worked Examples: Generics
- Conceptual Understanding: Objects
- Worked Examples: Objects
- Conceptual Understanding: Extensions
- Worked Examples: Extensions
- Conceptual Understanding: Scope Functions
- Worked Examples: Scope Functions
- Step-by-Step Build: A Generic Quiz Engine
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check