Abschnitt III: Benutzerfreundlichkeit

Programmiersprachen unterscheiden sich nicht so sehr darin, was sie möglich machen, sondern darin, was sie einfach machenLarry Wall, Erfinder der Perl-Sprache

Erweiterungsfunktionen

Angenommen, Sie entdecken eine Bibliothek, die fast alles tut, was Sie brauchen… fast. Wenn sie nur ein oder zwei zusätzliche Mitgliedsfunktionen hätte, würde sie Ihr Problem perfekt lösen.

Aber es ist nicht Ihr Code – entweder haben Sie keinen Zugriff auf den Quellcode oder Sie kontrollieren ihn nicht. Sie müssten Ihre Änderungen bei jeder neuen Version wiederholen.

Kotlin’s extension functions fügen bestehenden Klassen effektiv Mitgliedsfunktionen hinzu. Der Typ, den Sie erweitern, wird receiver genannt. Um eine Erweiterungsfunktion zu definieren, setzen Sie den Typ des Empfängers vor den Funktionsnamen:

fun ReceiverType.extensionFunction() { ... }

Dies fügt der String-Klasse zwei Erweiterungsfunktionen hinzu:

// ExtensionFunctions/Quoting.kt
package extensionfunctions
import atomictest.eq

fun String.singleQuote() = "'$this'"
fun String.doubleQuote() = "\"$this\""

fun main() {
  "Hi".singleQuote() eq "'Hi'"
  "Hi".doubleQuote() eq "\"Hi\""
}

Sie rufen Erweiterungsfunktionen auf, als ob sie Mitglieder der Klasse wären.

Um Erweiterungen aus einem anderen Paket zu verwenden, müssen Sie sie importieren:

// ExtensionFunctions/Quote.kt
package other
import atomictest.eq
import extensionfunctions.doubleQuote
import extensionfunctions.singleQuote

fun main() {
  "Single".singleQuote() eq "'Single'"
  "Double".doubleQuote() eq "\"Double\""
}

Sie können auf Mitgliederfunktionen oder andere Erweiterungen mit dem Schlüsselwort this zugreifen. this kann auch weggelassen werden, ebenso wie es innerhalb einer Klasse weggelassen werden kann, sodass Sie keine explizite Qualifizierung benötigen:

// ExtensionFunctions/StrangeQuote.kt
package extensionfunctions
import atomictest.eq

// Apply two sets of single quotes:
fun String.strangeQuote() =
  this.singleQuote().singleQuote()  // [1]

fun String.tooManyQuotes() =
  doubleQuote().doubleQuote()       // [2]

fun main() {
  "Hi".strangeQuote() eq "''Hi''"
  "Hi".tooManyQuotes() eq "\"\"Hi\"\""
}
  • [1] this bezieht sich auf den String-Empfänger.
  • [2] Wir lassen das Empfängerobjekt (this) beim ersten Aufruf der Funktion doubleQuote() weg.

Die Erweiterung Ihrer eigenen Klassen kann manchmal zu einfacherer Code führen:

// ExtensionFunctions/BookExtensions.kt
package extensionfunctions
import atomictest.eq

class Book(val title: String)

fun Book.categorize(category: String) =
  """title: "$title", category: $category"""

fun main() {
  Book("Dracula").categorize("Vampire") eq
    """title: "Dracula", category: Vampire"""
}

Innerhalb von categorize() greifen wir ohne explizite Qualifikation auf die title-Eigenschaft zu.

  • -

Erweiterungsfunktionen können nur auf öffentliche Elemente des zu erweiternden Typs zugreifen. Daher können Erweiterungen dieselben Aktionen wie reguläre Funktionen ausführen. Sie können Book.categorize(String) als categorize(Book, String) umschreiben. Der einzige Grund für die Verwendung einer Erweiterungsfunktion ist die Syntax, aber dieser syntaktische Zucker ist mächtig. Für den aufrufenden Code sehen Erweiterungen genauso aus wie Mitgliedsfunktionen, und IDEs zeigen Erweiterungen an, wenn sie die Funktionen auflisten, die Sie für ein Objekt aufrufen können.

Übungen und Lösungen finden Sie unter www.AtomicKotlin.com.

Benannte & Standardargumente

Sie können während eines Funktionsaufrufs Argumentnamen angeben.

Benannte Argumente verbessern die Code-Lesbarkeit. Dies gilt besonders für lange und komplexe Argumentlisten – benannte Argumente können so klar sein, dass der Leser einen Funktionsaufruf verstehen kann, ohne die Dokumentation anzusehen.

In diesem Beispiel sind alle Parameter Int. Benannte Argumente verdeutlichen ihre Bedeutung:

// NamedAndDefaultArgs/NamedArguments.kt
package color1
import atomictest.eq

fun color(red: Int, green: Int, blue: Int) =
  "($red, $green, $blue)"

fun main() {
  color(1, 2, 3) eq "(1, 2, 3)"   // [1]
  color(
    red = 76,                     // [2]
    green = 89,
    blue = 0
  ) eq "(76, 89, 0)"
  color(52, 34, blue = 0) eq      // [3]
    "(52, 34, 0)"
}
  • [1] Dies sagt Ihnen nicht viel. Sie müssen die Dokumentation einsehen, um zu verstehen, was die Argumente bedeuten.
  • [2] Die Bedeutung jedes Arguments ist klar.
  • [3] Es ist nicht erforderlich, alle Argumente zu benennen.

Benannte Argumente ermöglichen es Ihnen, die Reihenfolge der Farben zu ändern. Hier geben wir blue zuerst an:

// NamedAndDefaultArgs/ArgumentOrder.kt
import color1.color
import atomictest.eq

fun main() {
  color(blue = 0, red = 99, green = 52) eq
    "(99, 52, 0)"
  color(red = 255, 255, 0) eq
    "(255, 255, 0)"
}

Sie können benannte und reguläre (positionale) Argumente mischen. Wenn Sie die Reihenfolge der Argumente ändern, sollten Sie benannte Argumente im gesamten Aufruf verwenden—nicht nur der Lesbarkeit halber, sondern oft muss der Compiler wissen, wo die Argumente sind.

Benannte Argumente sind noch nützlicher, wenn sie mit Standardargumenten kombiniert werden, die Standardwerte für Argumente sind, die in der Funktionsdefinition angegeben sind:

// NamedAndDefaultArgs/Color2.kt
package color2
import atomictest.eq

fun color(
  red: Int = 0,
  green: Int = 0,
  blue: Int = 0,
) = "($red, $green, $blue)"

fun main() {
  color(139) eq "(139, 0, 0)"
  color(blue = 139) eq "(0, 0, 139)"
  color(255, 165) eq "(255, 165, 0)"
  color(red = 128, blue = 128) eq
    "(128, 0, 128)"
}

Jedes Argument, das Sie nicht angeben, erhält seinen Standardwert. Daher müssen Sie nur die Argumente angeben, die von den Standardwerten abweichen. Wenn Sie eine lange Argumentliste haben, vereinfacht dies den resultierenden Code, was das Schreiben und—was noch wichtiger ist—das Lesen erleichtert.

Dieses Beispiel verwendet auch ein nachgestelltes Komma in der Definition von color(). Das nachgestellte Komma ist das zusätzliche Komma nach dem letzten Parameter (blue). Dies ist nützlich, wenn Ihre Parameter oder Werte über mehrere Zeilen geschrieben sind. Mit einem nachgestellten Komma können Sie neue Elemente hinzufügen und ihre Reihenfolge ändern, ohne Kommas hinzuzufügen oder zu entfernen.

Benannte und Standardargumente (sowie nachgestellte Kommas) funktionieren auch für Konstruktoren:

// NamedAndDefaultArgs/Color3.kt
package color3
import atomictest.eq

class Color(
  val red: Int = 0,
  val green: Int = 0,
  val blue: Int = 0,
) {
  override fun toString() =
    "($red, $green, $blue)"
}

fun main() {
  Color(red = 77).toString() eq "(77, 0, 0)"
}

joinToString() ist eine Standardbibliotheksfunktion, die Standardargumente verwendet. Sie kombiniert die Inhalte eines iterierbaren Objekts (einer Liste, Menge oder eines Bereichs) zu einem String. Sie können einen Trennzeichen, ein Präfixelement und ein Suffixelement angeben:

// NamedAndDefaultArgs/CreateString.kt
import atomictest.eq

fun main() {
  val list = listOf(1, 2, 3,)
  list.toString() eq "[1, 2, 3]"
  list.joinToString() eq "1, 2, 3"
  list.joinToString(prefix = "(",
    postfix = ")") eq "(1, 2, 3)"
  list.joinToString(separator = ":") eq
    "1:2:3"
}

Der Standardwert von toString() für eine List gibt den Inhalt in eckigen Klammern zurück, was möglicherweise nicht das ist, was Sie wollen. Die Standardwerte für die Parameter von joinToString() sind ein Komma für separator und leere Strings für prefix und postfix. Im obigen Beispiel verwenden wir benannte und Standardargumente, um nur die Argumente zu spezifizieren, die wir ändern möchten.

Der Initialisierer für list beinhaltet ein abschließendes Komma. Normalerweise verwenden Sie ein abschließendes Komma nur, wenn jedes Element in einer eigenen Zeile steht.

Wenn Sie ein Objekt als Standardargument verwenden, wird bei jedem Aufruf eine neue Instanz dieses Objekts erstellt:

Wenn Sie eine Objektinstanz als Standardargument übergeben (da innerhalb von g() im folgenden Beispiel), wird dieselbe Instanz für jeden Aufruf von g() verwendet. Wenn Sie die Syntax für einen Konstruktoraufruf übergeben (DefaultArg() innerhalb von h()), wird dieser Konstruktor jedes Mal aufgerufen, wenn Sie h() aufrufen:

// NamedAndDefaultArgs/Evaluation.kt
package namedanddefault

class DefaultArg
val da = DefaultArg()

fun g(d: DefaultArg = da) = println(d)

fun h(d: DefaultArg = DefaultArg()) =
  println(d)

fun main() {
  g()
  g()
  h()
  h()
}
/* Sample output:
namedanddefault.DefaultArg@7440e464
namedanddefault.DefaultArg@7440e464
namedanddefault.DefaultArg@49476842
namedanddefault.DefaultArg@78308db1
*/

Die Ausgabe der beiden g() Aufrufe zeigt identische Objektadressen. Bei den beiden Aufrufen von h() sind die Adressen der DefaultArg Objekte unterschiedlich, was zeigt, dass es zwei verschiedene Objekte gibt.

Geben Sie Argumentnamen an, wenn sie die Lesbarkeit verbessern. Vergleichen Sie die folgenden beiden Aufrufe von joinToString():

// NamedAndDefaultArgs/CreateString2.kt
import atomictest.eq

fun main() {
  val list = listOf(1, 2, 3)
  list.joinToString(". ", "", "!") eq
    "1. 2. 3!"
  list.joinToString(separator = ". ",
    postfix = "!") eq "1. 2. 3!"
}

Es ist schwer zu erraten, ob ". " oder "" ein Trennzeichen ist, es sei denn, man merkt sich die Reihenfolge der Parameter, was unpraktisch ist.

Ein weiteres Beispiel für Standardargumente ist die trimMargin()-Funktion der Standardbibliothek, die mehrzeilige Strings formatiert. Sie verwendet einen Randpräfix-String, um den Anfang jeder Zeile festzulegen. trimMargin() entfernt führende Leerzeichen, gefolgt von dem Randpräfix, aus jeder Zeile des Quell-String. Es entfernt die erste und letzte Zeile, wenn sie leer sind:

// NamedAndDefaultArgs/TrimMargin.kt
import atomictest.eq

fun main() {
  val poem = """
    |->Last night I saw upon the stair
        |->A little man who wasn't there
          |->He wasn't there again today
|->Oh, how I wish he'd go away."""
  poem.trimMargin() eq
"""->Last night I saw upon the stair
->A little man who wasn't there
->He wasn't there again today
->Oh, how I wish he'd go away."""
  poem.trimMargin(marginPrefix = "|->") eq
"""Last night I saw upon the stair
A little man who wasn't there
He wasn't there again today
Oh, how I wish he'd go away."""
}

Das | (“Pipe”) ist das Standardargument für das Randpräfix, und Sie können es durch einen String Ihrer Wahl ersetzen.

Übungen und Lösungen finden Sie auf www.AtomicKotlin.com.

Überladung

Sprachen ohne Unterstützung für Standardargumente verwenden oft Überladung, um dieses Merkmal zu imitieren.

Der Begriff Überladung bezieht sich auf den Namen einer Funktion: Sie verwenden denselben Namen (“überladen” diesen Namen) für verschiedene Funktionen, solange sich die Parameterlisten unterscheiden. Hier überladen wir die Memberfunktion f():

// Overloading/Overloading.kt
package overloading
import atomictest.eq

class Overloading {
  fun f() = 0
  fun f(n: Int) = n + 2
}

fun main() {
  val o = Overloading()
  o.f() eq 0
  o.f(11) eq 13
}

In Overloading sehen Sie zwei Funktionen mit demselben Namen, f(). Die Signatur einer Funktion besteht aus dem Namen, der Parameterliste und dem Rückgabetyp. Kotlin unterscheidet eine Funktion von einer anderen, indem es die Signaturen vergleicht. Beim Überladen von Funktionen müssen die Parameterlisten einzigartig sein—man kann nicht nur über die Rückgabetypen überladen.

Die Aufrufe zeigen, dass es sich tatsächlich um unterschiedliche Funktionen handelt. Eine Funktionssignatur beinhaltet auch Informationen über die umschließende Klasse (oder den Empfangstyp, wenn es sich um eine Erweiterungsfunktion handelt).

Wenn eine Klasse bereits eine Mitgliedsfunktion mit derselben Signatur wie eine Erweiterungsfunktion hat, bevorzugt Kotlin die Mitgliedsfunktion. Sie können jedoch die Mitgliedsfunktion mit einer Erweiterungsfunktion überladen:

// Overloading/MemberVsExtension.kt
package overloading
import atomictest.eq

class My {
  fun foo() = 0
}

fun My.foo() = 1             // [1]

fun My.foo(i: Int) = i + 2   // [2]

fun main() {
  My().foo() eq 0
  My().foo(1) eq 3
}
  • [1] Es ist sinnlos, eine Erweiterung zu deklarieren, die ein Mitglied dupliziert, da sie niemals aufgerufen werden kann.
  • [2] Sie können eine Mitgliedsfunktion mit einer Erweiterungsfunktion überladen, indem Sie eine andere Parameterliste bereitstellen.

Verwenden Sie das Überladen nicht, um Standardargumente zu imitieren. Das heißt, tun Sie dies nicht:

// Overloading/WithoutDefaultArguments.kt
package withoutdefaultarguments
import atomictest.eq

fun f(n: Int) = n + 373
fun f() = f(0)

fun main() {
  f() eq 373
}

Die Funktion ohne Parameter ruft einfach die erste Funktion auf. Die beiden Funktionen können durch eine einzelne Funktion ersetzt werden, indem ein Standardargument verwendet wird:

// Overloading/WithDefaultArguments.kt
package withdefaultarguments
import atomictest.eq

fun f(n: Int = 0) = n + 373

fun main() {
  f() eq 373
}

In beiden Beispielen können Sie die Funktion entweder ohne ein Argument oder durch Übergeben eines Ganzzahlwerts aufrufen. Bevorzugen Sie die Form in WithDefaultArguments.kt.

Bei der Verwendung von überladenen Funktionen zusammen mit Standardargumenten sucht der Aufruf der überladenen Funktion nach der “nächsten” Übereinstimmung. Im folgenden Beispiel ruft der foo()-Aufruf in main() nicht die erste Version der Funktion mit ihrem Standardargument von 99 auf, sondern stattdessen die zweite Version, die ohne Parameter:

// Overloading/OverloadedVsDefaultArg.kt
package overloadingvsdefaultargs
import atomictest.*

fun foo(n: Int = 99) = trace("foo-1-$n")

fun foo() {
  trace("foo-2")
  foo(14)
}

fun main() {
  foo()
  trace eq """
    foo-2
    foo-1-14
  """
}

Sie können das Standardargument 99 niemals nutzen, da foo() immer die zweite Version von f() aufruft.

Warum ist das Überladen nützlich? Es ermöglicht Ihnen, “Variationen eines Themas” klarer auszudrücken, als wenn Sie gezwungen wären, unterschiedliche Funktionsnamen zu verwenden. Angenommen, Sie möchten Additionsfunktionen:

// Overloading/OverloadingAdd.kt
package overloading
import atomictest.eq

fun addInt(i: Int, j: Int) = i + j
fun addDouble(i: Double, j: Double) = i + j

fun add(i: Int, j: Int) = i + j
fun add(i: Double, j: Double) = i + j

fun main() {
  addInt(5, 6) eq add(5, 6)
  addDouble(56.23, 44.77) eq
    add(56.23, 44.77)
}

addInt() nimmt zwei Ints und gibt ein Int zurück, während addDouble() zwei Doubles nimmt und ein Double zurückgibt. Ohne Überladen kann man die Operation nicht einfach add() nennen, daher kombinieren Programmierer typischerweise was mit wie, um eindeutige Namen zu erzeugen (man kann auch eindeutige Namen mit zufälligen Zeichen erstellen, aber das typische Muster ist die Verwendung von aussagekräftigen Informationen wie Parametertypen). Im Gegensatz dazu ist das überladene add() viel klarer.

  • -

Das Fehlen des Überladens in einer Sprache ist keine große Bürde, aber das Feature bietet wertvolle Vereinfachung, wodurch der Code lesbarer wird. Mit Überladung sagt man einfach was, was die Abstraktionsebene erhöht und die geistige Belastung für den Leser verringert. Wenn man wissen will wie, schaut man sich die Parameter an. Beachten Sie auch, dass Überladen Redundanz reduziert: Wenn wir addInt() und addDouble() sagen müssen, wiederholen wir im Wesentlichen die Parameterinformationen im Funktionsnamen.

Übungen und Lösungen finden Sie auf www.AtomicKotlin.com.