Do you want to pick up from where you left of?
Take me there

Spezifikationen und Typen

In dieser Lektion werden wir die @spec- und @type-Syntax kennen lernen. Die Erste ist eine Ergänzung um Dokumentation zu schreiben, die von Tools ausgewertet werden kann. Die Zweite hilft uns lesbareren und einfacheren Code zu schreiben.

Einführung

Es ist nicht ungewöhnlich, dass du das Interface deiner Funktionen beschreiben möchtest. Natürlich kannst du die @doc Annotation benutzen, aber das ist nur Information für andere Entwickler, welche nicht während der Kompiliervorgangs überprüft wird. Aus diesem Grund bietet Elixir @spec-Annotation, um Spezifikationen von Funktionen zu beschreiben, die vom Compiler überprüft werden.

Jedoch sind Spezifikationen in manchen Fällen ziemlich groß und umständlich. Falls du Komplexität reduzieren möchtest, möchtest du benutzerdefinierte Typdefinitionen einführen. Elixir hat dafür @type-Annotationen. Andererseits ist Elixir immer noch eine dynamische Sprache. Das bedeutet jegliche Information über Typen wird vom Compiler ignoriert, kann jedoch von anderen Tools benutzt werden.

Spezifikationen

Falls du bereits Erfahrung mit Java hast, sind dir Spezifikationen als interface vertraut. Spezifikationen definieren welche Typen Funktionsparameter und Rückgabewerte haben.

Um Eingabe- und Ausgabewerte zu definieren benutzen wir die @spec-Direktive direkt vor der Funktionsdefinition und nehmen den Funktionsnamen, die Liste der Parametertypen, sowie nach :: die Typen der Rückgabewerte.

Schau dir das folgende Beispiel an:

@spec sum_product(integer) :: integer
def sum_product(a) do
  [1, 2, 3]
  |> Enum.map(fn el -> el * a end)
  |> Enum.sum()
end

Das sieht auf den ersten Blick alles OK aus, jedoch gibt die Funktion Enum.sum eine number und nicht einen integer wie in @spec angegeben zurück. Das könnte Bugs verursachen! Es gibt Tools wie Dialyzer zur statischen Analyse, die uns helfen solche Art Bugs zu finden. Wir werden sie in einer anderen Lektion besprechen.

Benutzerdefinierte Typen

Spezifikationen schreiben ist ganz nett, aber manchmal arbeiten unsere Funktionen mit komplexeren Datenstrukturen als einfache Nummern oder collections. In diesem Fall wäre die Definition in @spec schwer für andere Entwickler zu verstehen oder zu ändern. Manchmal müssen Funktionen eine große Zahl Parameter entgegennehmen oder komplexe Daten zurückgeben. Eine lange Parameterliste ist eine der vielen Möglichkeiten für code smells im Code. In objektorientieren Sprachen wie Ruby oder Java könnten wir einfach Klassen definieren, die uns dabei helfen, dieses Problem zu lösen. Elixir kennt keine Klassen, ist aber einfach zu erweitern, so dass wir unsere Typen definieren können. Standardmäßig bietet Elixir einfache Typen wie integer oder pid. Du findest eine Liste aller Typen in der Dokumentation.

Typen definieren

Lass uns unsere sum_times-Funktion verändern und ein paar extra Parameter einfügen:

@spec sum_times(integer, %Examples{first: integer, last: integer}) :: integer
def sum_times(a, params) do
  for i <- params.first..params.last do
    i
  end
  |> Enum.map(fn el -> el * a end)
  |> Enum.sum()
  |> round
end

Wir haben ein struct im Examples-Modul hinzugefügt, das die beiden Felder first und last beinhaltet. Das ist die einfachere Version des structs aus dem Range-Modul. Wir sprechen noch über structs, wenn wir Module besprechen. Lass uns davon ausgehen, dass wir das Examples-struct an vielen Stellen brauchen und es nervtötend ist lange, komplexe Spezifikationen zu schreiben, die Bugs herbeiführen könnten. Eine Lösung für dieses Problem ist @type.

Elixir hat drei Direktiven für Typen:

Lass uns unseren Typ definieren:

defmodule Examples do
  defstruct first: nil, last: nil

  @type t(first, last) :: %Examples{first: first, last: last}

  @type t :: %Examples{first: integer, last: integer}
end

Wir haben den Typ t(first, last) bereits definiert, welcher eine Repräsentation des structs %Examples{first: first, last: last} ist. An diesem Punkt sehen wir, dass Typen Parameter entgegen nehmen können, aber wir haben den Typ t ebenfalls definiert und diesmal ist er eine Repräsentation des structs %Examples{first: integer, last: integer}.

Wo ist der Unterschied? Der erste Fall repräsentiert das struct Examples, bei dem die zwei keys von jedem Typ sein könnten. Im zweiten Fall repräsentiert ein struct dessen keys integer sind. Das bedeutet Code wie:

@spec sum_times(integer, Examples.t()) :: integer
def sum_times(a, params) do
  for i <- params.first..params.last do
    i
  end
  |> Enum.map(fn el -> el * a end)
  |> Enum.sum()
  |> round
end

Ist gleichbedeutend zu:

@spec sum_times(integer, Examples.t(integer, integer)) :: integer
def sum_times(a, params) do
  for i <- params.first..params.last do
    i
  end
  |> Enum.map(fn el -> el * a end)
  |> Enum.sum()
  |> round
end

Dokumentation von Typen

Der letzte Punkt, über den wir reden sollten, ist wie man Typen dokumentiert. Wie wir bereits aus der Dokumentation-Lektion wissen gibt es @doc- und @moduledoc-Annotationen, um Dokumentation für Funktionen und Module zu schreiben. Um Typen zu dokumentieren können wir @typedoc nutzen:

defmodule Examples do
  @typedoc """
      Type that represents Examples struct with :first as integer and :last as integer.
  """
  @type t :: %Examples{first: integer, last: integer}
end

Die Direktive @typedoc ist ähnlich zu @doc und @moduledoc.

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!