3.1 Extension Functions
Suppose you discover a library that does everything you need ... almost. If it only has one or two additional member functions, it would solve your problem perfectly.
But it's not your code—either you don't have access to the source code or you don't control it. You'd have to repeat your modifications every time a new version came out.
Kotlin's extension functions effectively add member functions to existing classes. The type you extend is called receiver. To define an extension function, you precede the function name with the receiver type:
This adds two extension functions to the String class:
You call extension functions as if they were members of the class.
To use extensions from another package, you must import them:
You can access member of functions or other extension using the this keyword. this can also be omitted in the same way it can be omitted inside a class, so you don't need explicit qualification:
•
[1] this refers to the String receiver.
•
[2] We omit the receiver object (this) of the first doubleQuote() function call.
Creating extensions to your own classes can sometimes produce simpler code:
Inside categorizee(), we access the title property without explicit qualification.
Note that extension functions can only access public elements of the type being extended. Thus, extension can only perform the same actions as regular functions. You can rewrite Book.categorize(String) as categorize(Book, String). The only reason for using an extenstion function is the syntax, but this syntax sugar is powerful. To the calling code, extensions look the same as member functions, and IDEs show extensions when listing the functions that you can call for an object.
3.2 Named & Default Arguments
You can provide argument names during a function call.
Named arguments improve code readability. This is especially true for long and complex argument lists—named arguments can be clear enough that the reader can understand a function call without looking at the documentation.
In this example, all parameters are Int. Named arguments clarify their meaning:
•
[1] This doesn't tell you much. You'll have to look at the documentation to know what the arguments mean.
•
[2] The meaning of every argument is clear.
•
[3] You aren't required to name all arguments.
Named arguments allow you to change the order of the colors. Here, we specify blue first:
You can mix named and regular (positional) arguments. If you change argument order, you should use named arguments throughout the call—not only for readability, but the compiler often needs to be told where the arguments are.
Named arguments are even more useful when combined with default arguments, which are default values for arguments, specified in the function definition:
Any argument you don't provide gets its default value, so you only need to provide arguments that differ from the defaults. If you have a long argument list, this simplifies the resulting code, making it easier to write and—more importantly—to read.
This example also uses a trailing comma in the definition for color(). The trailing comma is the extra comma after the last parameter (blue). This is useful when your parameters or values are written on multiple lines. With a trailing comma, you can add new items and change their order without adding or removing commas.
Named and default arguments (as well as trailing commas) also work for constructors:
JoinToString() is standard library function that uses default arguments. It combines the contents fo an iterable (a list, set, or range) into a String. You can specify a separator, a prefix element and postfix element:
The default toString() for a List returns the contents in square brackets, which might not be what you want. The default values for joinToString()s parameters are a comma for separator and empty Strings for prefix and postfix. In the above example, we use named and default arguments to specify only the arguments we want to change.
The initializer for list includes a trailing comma. Normally you'll only use a trailing comma when each element is on its own line.
If you use an object as a default argument, a new instance of that object is created for each invocation:
The addresses of the Default objects are different for the two calls to h(), showing that there are two distinct objects.
Specify argument names when they improve readability. Compare the following two calls to joinToString():
It's hard to guess whether ". " or "" is a separator unless you memorize the parameter order, which is impractical.
As another example of default arguments, trimMargin() is a standard library function that formats multi-line Strings. It uses a margin prefix String to establish the beginning of each line. trimMargin() trims leading whitespace characters followed by the margin prefix from every line of the source String. It removes the first and last lines if they are blank:
The | ("pipe") is the default argument for the margin prefix, and you can replace it with a String of your choosing.
3.3 Overloading
Languages without support for default arguments often use overloading to imitate that feature.
The term overload refers to the name of a function: You can the same name ("overload" that name) for different functions as long as the parameter lists differ. Here, we overload the member function f():
In Overloading, you see two functions with the same name, f(). The function's signature consists of the name, parameter list and return type. Kotlin distinguishes one function from another by comparing signatures. When overloading functions, the parameter lists must be unique—you cannot overload on return types.
The calls show that they are indeed different functions. A function signature also includes information about the enclosing class (or the receiver type, if it's an extension function).
Note that if a class already has a member function with the same signature as an extension function, Kotlin prefers the member function. However, you can overload the member function with an extension function:
•
[1] It's senseless to declare an extension that duplicates a member, because it can never be called.
•
[2] You can overload a member function using an extension function by providing a different parameter list.
Don't use overloading to imitate default arguments. That is, don't do this:
The function without parameters just class the first function. The two functions can be replaced with a signle function by using a default argument:
In both examples you can call the function either without an argument or by passing an integer value. Prefer the form in WithDefaultArguments.kt.
When using overloaded functions together with default arguments, calling the overloaded function searches for the "closest" match. In the following example, the foo() call in main() does not call the first version of the function using its default argument of 99, but instead calls the second version, the one without parameters:
You can never utilize the default argument of 99, because foo() always calls the second version of f().
Why is overloading useful? It allows you to express "variations on a theme" more clearly than if you were forced to use different function names. Suppose you want addition functions:
addInt() takes two Ints and returns an Int, while addDouble() takes two Doubles and retruns a Double. Without overloading, you can't just name the operation add(), so programmers typically conflate what with how to produce unquie names (you can aslo create unique names using random characters but the typical pattern is to use meaningful information like parameter types). In contrast, the overloaded add() is much clearer.
The lack of overloading in a language is not a terrible hardship, but the feature provides valuable simplification, producing more readable code. With overloading, you just say what, which raises the level of abstraction and puts less mental load on the reader. If you want to know how, look at the parameters. Notice also that overloading reduces redundancy: If we must say addInt() and addDouble(), then we essentially repeat the parameter information in the function name.
3.4 when Expressions
A large of computer programming is performing an action when a pattern matches.
Anything that simplifies this task is a boon for programmers. If you have more than two or three choice to make, when expressions are much nicer than if expressions.
A when expression compares a value against a selection of possibilities. It begins with the keyword when and the parenthesized value to compare. This is followed by a body containing a set of possible matches and their associated actions. Each match is an expression followed by a right arrow ->. The arrow is the two separate characters - and > with no white space between them. The expression is evaluated and compared to the target value. If it matches, the expression to the right of the -> produces the result of the when expression.
ordinal() in the following example builds the German word for an ordinal number based on a word for the cardinal number. It matches an integer to a fixed set of numbers to check whether it applies to a general rule or is an exception (which happens painfully often in German):
•
[1] The when expression compares i to the match expressions in the body.
•
[2] The first successful match completes execution of the when expression—here, a String is produced which becomes the return value of ordinal().
•
[3] The else keyword provides a "fall through" when there are no matches. The else case always appears last in the match list. When we test against 2, it doesn't match 1, 3, 7, 8 or 20, and so falls through to the else case.
If you forget the else branch in the example above, the compile-time error is: 'when' expression must be exhaustive, add necessary 'else' branch. If you treat a when expression as a statement—that is, you don't use the result of the when—you can omit the else branch. Unmatched values are then just ignored.
when은 expression, statement 둘 다 가능함
In the following example, Coordinates reports changes to its properties using Property Accessors. The when expression processes each item from inputs:
•
[1] input is matched against the different options.
•
[2] You can list several values in one branch using commas. Here, if the user enters either "up" or "u" we interpret it as a move up.
•
[3] Multiple actions within a branch must be in a block body.
•
[4] "Doing nothing" is expressed with an empty block.
•
[5] Returning from the outer function is a valid action within a branch. In this case, the return terminates the call to processInputs().
Any expression can be an argument for when, and the matches can be any values (not just constants):
when expressions can overlap the functionality of if expressions. when is more flexible, so prefer it over if when there's a choice.
We can match a Set of values against another Set of values:
Inside mixColors() we use a Set as a when argument and compare it with different Sets. We use a Set because the order of elements is unimportant—we need the same result when we mix "red" and "blue" as when we mix "blue" and "red."
when has a special form that takes no argument. Omitting the argument means the branches can check different Boolean conditions. You can use any Boolean expression as a branch condition. As an example, we rewrite bmiMetric() introduced in Number Types, first showing the original solution, then using when instead of if:
The solution using when is a more elegant way to choose between several options.
3.5 Enumerations
An enumeration is a collection of names.
Kotlin's enum class is a convenient way to manage these names:
Creating an enum generates toString()s for the enum names.
You must qualify each reference to an enumeration name, as with Level.Medium in main(). You can eliminates this qualification using an import to bring all names from the enumeration into the current namespace (namespaces keep names from colliding with each other):
•
[1] The * imports all the names inside the Level enumeration, but does not import the name Level.
You can import enum values into the same file where the enum class is defined:
•
[1] We import the values of Size before the Size definition appears in the file.
•
[2] After the import, we no longer need to qualify access to the enumeration names.
•
[3] You can iterate through the enumeration names using values(). values() returns an Array, so we call toList() to convert it to a List.
•
[4] The first declared constant of an enum has an ordinal value of zero. Each subsequent constant receives the next integer value.
You can perform different actions for different enum entries using a when expression. Here we import the name Level, as well as all its entries:
checkLevel() performs specific actions for only two of the constants, while behaving ordinarily (the else case) for all other options.
An enumeration is a special kind of class with a fixed number of instances, all listed within the class body. In other ways, an enum class behaves like a regular class, so you can define member properties and functions. If you include additional elements, you must add a semicolon after the last enumeration value:
신기...
The Direction class contains a notation property holding a different value for each instance. You pass values for the notationconstructor parameter in parentheses (North("N")), just like you construct an instance of a regular class.
The getter for the opposite property dynamically computes the result when it is accessed.
Notice that when doesn't require an else branch in this example, because all possible enum entries are covered.
Enumerations can make your code more readable, which is always desirable.
3.6 Data Classes
Kotlin reduces repetitive coding.
•
Creating classes that primarily hold data still requires a significant amount of repetitive code.
•
When you need a class that's essentially a data holder, data classes simplify your code and perform common task.
•
You define a data class using the data keyword, which tells Kotlin to generate additional functionality.
•
Each constructor parameter must be preceded by var or val:
This example reveals two features of data classes:
1.
The String produced by s1 is different than what we usually see; it includes the parameter names and values of the data held by the object. data classes display objects in a nice, readable format without requiring any additional code.
2.
If you create two instances of the same data class containing identical data (equal values for properties), you probably also want those two instances to be equal. To achieve that behavior for a regular class, you must define a special function equals() to compare instances. In data classes, this function is automatically generated; it compares the values of all properties specified as constructor parameters.
•
Here's an ordinary class Person and a data class Contact:
•
Because the Person class is defined without the data keyword, two instances containing the same name are not equal. Fortunately, creating Contact as a data class produces a reasonable result.
•
Another useful function generated for every data class is copy(), which creates a new object containing the data from the current object.
•
However, it also allows you to change selected values in the process:
•
The parameter names for copy() are identical to the constructor parameters.
•
All arguments have default values that are equal to the current values, so you provide only the ones you want to replace.
HashMap and HashSet
•
Creating a data class also generates an appropriate hash function so that objects can be used as keys in HashMap and Hash Sets:
•
HashCode() is used in conjunction with equals() to rapidly look up a Key in a HashMap or a HashSet.
•
Creating a correct hashCode() by hand is tricky and error-prone, so it is quite beneficial to have the data class do if for you.
•
3.7 Destructuring Declarations
Suppose you want to return more than one item from a function, such as a result along with some information about that result.
The Pair class, which is part of the standard library, allow you to return two values:
We specify the return type of compute() as Pair<Int, String>. A Pair is a parameterized type, like List or Set.
Returning multiple values is helpful, but we'd also like a convenient way to unpack the results. As shown above, you can access the components of a Pair using its first and second properties, but you can also declare and initialize several identifiers simultaneously using a destructuring declaration:
This destructures a composed value and positionally assigns its components. The syntax differs from defining a single identifier—for destructuring, you put the names of the identifiers inside parentheses.
Here's a destructuring declaration for the Pair returned from compute():
The Triple class combines three values, but that's as far as it goes. This is intentional: if you need to store more values, or if you find yourself using many Pairs or Triples, consider creating special classes instead.
It's clearer to return a Computation instead of a Pair<Int, String>. Choosing a good name for the result is almost as important as choosing a good self-explanatory name for the function itself. Adding or removing Computation information is simpler if it's a separate class rather than a Pair.
When you unpack an instance of a data class, you must assign values to the new identifiers in the same order you define the properties in the class:
•
[1] If you don't need some of the identifiers, you may use underscores instead of their names, or omit them completely if they appear at the end. Here, the unpacked values 1 and 3.14 are discarded using underscores, "Mouse" is captured into animal, and false and the empty List are discarded because they are at the end of the list.
The properties of a data class are assigned by order, not by name. If you destructure an object and later add a property anywhere except the end of its data class, that new property will be destructured on top of your previous identifier, producing unexpected results (see Exercise 3). If your custom data class has properties with identical types, the compiler can't detect misuse so you may want to avoid destructuring it. Destructuring library data classes like Pair or Triple is safe, because they don't change.
Using a for loop, you can iterate over a Map or a List of pairs (or other data classes) and destructure each element:
withIndex() is a standard library extension function for List. It returns a collection of IndexedValues, which can be destructured:
Destructuring declarations are only allowed for local vars and vals, and cannot be used to create class properties.
3.8 Nullable Types
Consider a function that sometimes produces "no result." When this happens, the function doesn't produce an error per se. Nothing went wrong, there's just "no answer".
A good example is retrieving a value from a Map. If the Map doesn't contain a value for a given key, it can't give you an answer and return a null reference to indicate "no value":
Languages like Java allow a result to be either null or a meaningful value. Unfortunately, if you treat null the same way you treat a meaningful value, you get a dramatic failure (In Java, this produces a NullPointerException; in a more primitive language like C, a nullpointer can crash the process or even the operating system or machine). The creator of the null reference, Tony Hoare, refers to it as "my billion-dollar mistake" (although it has arguably cost much more than that).
One possible solution to this problem is for a language to never allow nulls in the first place, and instead introduce a special "no value" indicator. Kotlin might have done this, except that it must interact with Java, and Java uses nulls.
Kotlin's solution is arguably the best compromise: types default to non-nullable. However, if something can produce a null result, you must append a question mark to the type name to explicitly tag that result as nullable:
•
[1] s1 can't contain a null reference. All the vars and vals we've created in the book so far are automatically non-nullable.
•
[2] The error message is: null can not be a value of a non-null type String.
•
[3] To define an identifier that can contain a null reference, you put a ? at the end of the type name. Such an identifier can contain either null or a regular value.
•
[4] Both nulls and regular non-nullable values can be stored in a nullable type.
•
[5] You can't assign an identifier of a nullable type to an identifier of a non-null type. Kotlin emits: Type mismatch: inferred type is String? but String was expected. Even if the actual value is non-null as in this case (we know it's "abc"), Kotlin won't allow it because they are two different types.
•
[6] If you use type inference, Kotlin produces the appropriate type. Here, s6 is nullable because s4 is nullable.
Even though it looks like we just modify an existing type by adding a ? at the end, we're actually specifying a different type. For example, String and String? are two different types. The String? type forbids the operations in lines [2] and [5], thus guaranteeing that a value of a non-nullable type is never null.
Retrieving a value from a Map using square brackets produces a nullable result, because the underlying Map implementation comes from Java:
Why is it important to know that a value can't be null? Many operations implicitly assume a non-nullable result. For example, calling a member function will fail with an exception if the receiver value is null. In Java such a call will fail with a NullPointerException (often abbreviated NPE). Because almost any value can be null in Java, any function invocation can fail this way. In these cases you must write code to check for null results, or rely on other parts of the code to guard against nulls.
In Kotlin you can't simply dereference (call a member function or access a member property) a value of a nullable type:
You can access members of a non-nullable type as in [1]. If you reference members of a nullable type, as in [2], Kotlin emits an error.
Values of most types are stored as references to the objects in memory. That's the meaning of the term dereference—to access an object, you retrieve its value from memory.
The most straightforward way to ensure that dereferencing a nullable type won't throw a NullPointerException is to explicitly check that the reference is not null:
After the explicit if-check, Kotlin allows you to dereference a nullable. But writing this if whenever you work with nullable types is too noisy for such a common operation. Kotlin has concise syntax to alleviate this problem, which you'll learn about in subsequent atoms.
Whenever you create a new class, Kotlin automatically includes nullable and non-nullable types:
As you can see, we didn't do anything special to produce the complementary nullable types—they're available by default.
3.9 Safe Calls & the Elvis Operator
Kotlin provides convenient operations for handling nullability.
Nullable types come with numerous restrictions. You can't simply dereference of a nullable type:
Uncommenting [1] produces a compile-time error: Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?.
A safe call replaces the dot (.) in a regular call with a question mark and a dot (?.), without intervening space. Safe calls access members of a nullable in a way that ensures no exceptions are thrown. They only perform an operation when the receiver is not null:
Line [1] calls echo() and produces results in the trace, while line [2] does nothing because the receiver s2 is null.
Safe calls are a clean way to capture results:
Line [2] achieves the same effect as line [1]. If the receiver is not null it performs a normal access (s.length). If the receiver is null it doesn't perform the s.length call (which would cause an exception), but produces null for the expression.
What if you need something more than the null produced by ?.? The Elvis operator provides an alternative. This operator is a question mark followed by a colon (?:), with no intervening space. It is named for an emoticon of the musician Elvis Presley, and is also a play on the words "else-if" (which sounds vaguely like "Elvis").
A number of programming languages provide a null coalescing operator that performs the same action as Kotlin's Elvis operator.
If the expression on the left of ?: is not null, that expression becomes the result. If the left-hand expression is null, then the expression on the right of the ?: becomes the result:
s1 is not null, so the Elvis operator produces "abc" as the result. Because s2 is null, the Elvis operator produces the alternate result of "—".
The Elvis operator is typically used after a safe call, to produce a meaningful value instead of the default null, as you see in [2]:
This checkLength() function is quite similar to the one in SafeCall.kt above. The expected parameter type is now non-nullable. [1] and [2] produce zero instead of null.
Safe calls allow you to write chained calls concisely, when some elements in the chain might be null and you're only interested in the final result:
When you chain access to several members using safe calls, the result is null if any intermediate expressions are null.
•
[1] The property alice.friend is null, so the rest of the calls return null.
•
[2] All intermediate calls produce meaningful values.
•
[3] An Elvis operator after the chain of safe calls provides an alternate value if any intermediate element is null.
3.10 Non-Null Assertions
A second approach to the problem of nullable types is to have special knowledge that the reference in question isn't null.
To make this claim, use the double exclamation point, !!, called the non-null assertion. If this looks alarming, it should: believing that something can't be null is the source of most null-related program failures (the rest come from not realizing that a null can happen).
x!! means "forget the fact that x might be null—I guarantee that it's not null." x!! produces x if x isn't null, otherwise it throws an exception:
The definition val s: String = x!! tells Kotlin to ignore what it thinks it knows about x and just assign it to s, which is a non-nullable reference. Fortunately, there's run-time support that throws a NullPointerException when x is null.
Ordinarily you won't use the !! by itself, but instead in conjunction with a . dereference:
If you limit yourself to a single non-null asserted call per line, it's easier to locate a failure when the exception gives you a line number.
The safe call ?. is a single operator, but a non-null asserted call consists of two operators: the non-null assertion (!!) and a dereference (.). As you saw in NonNullAssert.kt, you can use a non-null assertion by itself.
Avoid non-null assertions and prefer safe calls or explicit checks. Non-null assertions were introduced to enable interaction between Kotlin and Java, and for the rare cases when Kotlin isn't smart enough to ensure the necessary checks are performed.
If you frequently use non-null assertions in your code for the same operation, it's better to use a separate function with a specific assertion describing the problem. As an example, suppose your program logic requires a particular key to be present in a Map, and you prefer getting an exception instead of silently doing nothing if the key is absent. Instead of extracting the value with the usual approach (square brackets), getValue() throws NoSuchElementException if a key is missing:
Throwing the specific NoSuchElementException gives you more useful details when something goes wrong.
Optimal code uses only safe calls and special functions that throw detailed exception. Only use non-null asserted calls when you absolutely must. Although non-null assertions were included to support interaction with Java code, there are better ways to interact with Java, which you can learn about in Appendix B: Java Interoperability.
3.11 Extensions for Nullable Types
Sometimes it's not what it looks like.
s?.f() implies that s is nullable—otherwise you could simply call s.f(). Similarly, t.f() seems to imply that t is non-nullable because Kotlin doesn't require a safe call or programmatic check. However, t is not necessarily non-nullable.
The Kotlin standard library provides String extension functions, including:
•
isNullOrEmpty(): Tests whether the receiver String is null or empty.
•
isNullOrBlank(): Performs the same check as isNullOrEmpty() and allows the receiver String to consist solely of whitespace characters, including tabs (\t) and newlines (\n).
Here's a basic test of these functions:
The function names suggest they are for nullable types. However, even though s1 is nullable, you can call isNullOrEmpty() or isNullOrBlank() without a safe call or explicit check. That's because these are extension functions on the nullable type String?.
We can rewrite isNullOrEmpty() as a non-extension function that takes the nullable String s as a parameter:
Because s is nullable, we explicitly check for null or empty. The expression s == null || s.isEmpty() uses short-circuiting: if the first part of the expression is true, the rest of the expression is not evaluated, thus preventing a null pointer exception.
Extension functions use this to represent the receiver (the object of the type being extended). To make the receiver nullable, add ? to the type being extended:
isNullOrEmpty() is more readable as an extension function.
Take care when using extensions for nullable types. They are great for simple cases like isNullOrEmpty() and isNullOrBlank(), especially with self-explanatory names that imply the receiver might be null. In general, it's better to declare regular (non-nullable) extensions. Safe calls and explicit checks clarify the receiver's nullability, while extensions for nullable types may conceal nullability and confuse the reader of your code (probably, "future you").
3.12 Introduction to Generics
Generic create parameterized types: components that work across multiple types.
The term "generic" means "pertaining or appropriate to large groups of classes." The original intent of generics in programming languages was to provide the programmer maximum expressiveness when writing classes or functions, by loosening type constraints on those classes or functions.
One of the most compelling initial motivations for generics is to create collection classes, which you've seen in the Lists, Sets and Maps used for the examples in this book. A collection is an object that holds other objects. Many programs require you to hold a group of objects while you use them, so collections are one of the most reusable of class libraries.
Let's look at a class that holds a single object. This class specifies the exact type of that object:
RigidHolder is not a particularly reusable tool; it can't hold anything but an Automobile. We would prefer not to write a new type of holder for every different type. To achieve this, we use a type parameter instead of Automobile.
To define a generic type, add angle brackets (<>) containing one or more generic placeholders and put this generic specification after the class name. Here, the generic placeholder T represents the unknown type and is used within the class as if it were a regular type:
•
[1] GenericHolder stores a T, and its member function getValue() returns a T.
When you call getValue() as in [2], [3] or [4] , the result is automatically the right type.
It seems like we might be able to solve this problem with a "universal type"—a type that is the parent of all other types. In Kotlin, this universal type is called Any. As the name implies, Any allows any type of argument. If you want to pass a variety of types to a function and they have nothing in common, Any solves the problem.
At a glance, it looks like we might be able to use Any instead of T in GenericHolder.kt:
Any does in fact work for simple cases, but as soon as we need the specific type—to call bark() for the Dog—it doesn't work because we lose track of the fact that it's a Dog when it is assigned to the Any. When we pass a Dog as an Any, the result is just an Any, which has no bark().
Using generics retains the information that, in this case, we actually have a Dog, which means we can perform Dog operations on the object returned by getValue().
Generic Functions
To define a generic function, specify a generic type parameter in angle brackets before the function name:
d has type Dog because identity() is a generic function and returns a T.
The Kotlin standard library contains many generic extension functions for collections. To write a generic extension function, put the generic specification before the receiver. For example, notice how first() and firstOrNull() are defined:
first() and firstOrNull() work with any kind of List. To return a T, they must be generic functions.
Notice how firstOrNull() specifies a nullable return type. Line [1] shows that calling the function on List<Int> returns the nullable type Int?. Line [2] shows that calling firstOrNull() on List<String> returns String?. Kotlin requires the ? on lines [1] and [2]—take them out and see the error messages.
3.13 Extension Properties
Just as function can be extension function, properties can be extension properties.
The receiver type specification for extension properties is similar to the syntax for extension functions—the extended type comes right before the function or property name:
An extension property requires a custom getter. The property value is computed for each access:
Although you can convert any extension function without parameters into a property, we recommend thinking about it first. The reasons described in Property Accessors for choosing between properties and functions also apply to extension properties. Preferring a property over a function makes sense only if it's simple enough and improves readability.
You can define a generic extension property. Here, we convert firstOrNull()from Introduction to Generics to an extension property:
The Kotlin Style Guide recommends a function over a property if the function throws an exception.
When the generic argument type isn't used, you may replace it with *. This is called a star projection:
When you use List<*>, you lose all specific information about the type contained in the List. For example, an element of a List<*> can only be assigned to Any?:
We have no information whether a value stored in a List<*> is nullable or not, which is why it can be only assigned to nullable Any? type.
3.14 break & continue
Skip...