Kopfrechner (Linux-Magazin, August 2019)

Sogenannte Mathe-Genies können das: Jemand aus dem Publikum ruft "12. Dezember 2019", und das Genie überlegt kurz, verkündet "Donnerstag!" und ein Raunen geht durch den Saal, denn jedermann kann anhand der Kalenderfunktion seines Mobiltelefons überprüfen, dass das tatsächlich stimmt. Wie geht das? Verfügt der Zauberer über ein fotografisches Gedächtnis, hat alle Kalender im Kopf und sieht mit seinem inneren Auge einfach nach? Oder sind arithmetische Formeln im Kopf zu berechnen? Die Lösung ist überraschend einfach, der Magier muss nur im Geiste einige einfach zu merkende Regeln durchgehen, und schon steht der Wochentag zu einem Datum fest.

Ein ähnliches Kopfrechenverfahren, allerdings mit mehr und komplizierteren Rechenschritten, habe ich vor zwölf Jahren schon mal im Programmier-Snapshot vorgestellt [3]. Anschließend wies mich ein aufmerksamer Leser darauf hin, dass die Methode unnötig komplex sei und verwies auf das Doomsday-Verfahren ([2]), um das es heute geht.

Jüngster Tag

Nach der Doomsday-Methode fallen folgende Tage eines Jahres immer auf den gleichen Wochentag: 9.5., 5.9., 7.11., und 11.7., einfach zu merken nach der Formel "9-5 at 7/11", den Werkstunden an einem typischen Arbeitstag für einen Amerikaner (9-5 also 9 bis 17 Uhr) bei "Seven-Eleven", einer ebenfalls in Amerika ansässigen Kleinsupermarktkette, vergleichbar etwa mit deutschen Tankstellenshops. Fast alle restlichen Doomsday-Tage fallen auf Tag/Monat-Doubletten, nämlich 4.4., 6.6., 8.8., 10.10. und 12.12., und nur Januar, Februar und März muss man sich gesondert merken, in Nicht-Schaltjahren ist es der 3. Januar, der 7. Februar und der 7. März (Abbildung 1). In Schaltjahren wechselt der Doomsday dieser Ausnahmemonate auf den 4. Januar und den 8. Februar. Der 7. März bleibt gleich.

Abbildung 1: Wer diese einfach zu merkende Tabelle im Kopf hat, kann zu jedem beliebigen Datum den Wochentag ausrechnen (Quelle: Wikipedia)

Nun steht der Doomsday für das Jahr 2019 nach einem gesonderten Verfahren als Donnerstag fest (2018 war Mittwoch, 2020 wird Samstag sein, siehe Abbildung 2), fragt also jemand nach dem 4.4.2019, steht die Antwort blitzschnell fest: Donnerstag, den der 4.4. ist der Doomsday. Wie steht es mit dem 25.4.2019, auf welchen Wochentag fiel dieses Datum? Ebenfalls Donnerstag natürlich, denn der 25. ist exakt 21 Tage nach dem 4., also auf den Tag genau drei Wochen später. Der 12. November 2019? Wegen der "9-5 at 7/11"-Regel ist der 7.11. ein Donnerstag, also ist der 12. November fünf Tage später, und damit ein Dienstag. Die Zählerei der Wochentage bewältigt der Zauberer dabei entweder im Kopf, mit den Fingern, oder indem er die Wochentage Sonntag bis Samstag von 0 bis 6 durchnumeriert, und dann vom Ergebnis den Rest einer Division durch 7 ausrechnet. Donnerstag ist in diesem Schema die vier, dazu fünf dazu addiert ergibt neun, und bei der Division durch sieben bleiben zwei übrig, also Dienstag.

Abbildung 2: Die Tabelle offenbart, dass der Doomsday im Jahr 2019 auf Donnerstag fällt (Quelle: Wikipedia)

Spielerisch zum Erfolg

Oder der 1. Januar 2020? Nächstes Jahr fällt der Doomsday laut der Tabelle in Abbildung 2 auf Samstag, also ist der 4.1. (Vorsicht, Schaltjahr!) ein Samstag und damit der Neujahrstag ein Mittwoch. Langsam lichtet sich also der Schleier und die Wahrheit kommt zutage: Da ist keine Zauberei im Spiel, sondern Merkregeln, die jeder einfach üben kann, bevor es auf die große Bühne geht. Um diese zu trainieren, wählt das diesmal im Snapshot vorgestellte Go-Programm ein zufälliges Datum im aktuellen Jahr aus, und lässt dem User die Wahl zwischen sieben Wochentagen. Klickt dieser nach Anwenden der Formel mit der Maus auf den richtigen Tag, bekommt er einen Punkt und der Zähler rechts oben in der Anzeige erhöht sich um Eins (Abbildung 3). Die Anzeige wechselt zu einem neuen Datum und das Spiel setzt sich fort.

Abbildung 3: Der Spieler hat den Wochentag richtig ermittelt und bekommt einen Punkt.

Verrechnet sich der Spieler hingegen und tippt auf den falschen Wochentag, folgt die grausame Strafe auf dem Fuß: Alle bislang erspielten Punkte verfallen und der Zähler springt zurück auf Null (Abbildung 4). Anschließend darf der Spieler nochmal nachrechnen und den richtigen Wochentag auswählen, worauf er einen Punkt bekommt und langsam neue Höhen im Punkte-Olymp erklimmen kann.

Abbildung 4: Oh nein, falsch getippt! Der Zähler springt zurück auf Null.

Das Spiel läuft in einer Terminal-UI, nachdem der User es von der Kommandozeile aus gestartet hat. So kann auch ein erschöpfter Systemadministrator im Rechenzentrum mal ein kleines Spielpäuschen zur Entspannung einlegen. Go und die schon einmal in einem früheren Snapshot ([4]) vorgestellte Library termui laufen auf allen erdenklichen Plattformen, auf Linux natürlich, aber auch auf anderen Unix-Derivaten und auch auf dem Mac und sogar auf Windows.

Um aus dem Go-Code von Listing 1 ein lauffähiges Binary zu erzeugen, legt der User zunächst ein neues Go-Modul (ab go-1.12) an und leitet dann mit "build" den Compilationsprozess ein, der automatisch alle als Abhängigkeiten entdeckten Libaries vom Netz holt und gleich mitcompiliert:

    $ go mod init dateday
    $ go build dateday.go

Wie der Installationsprozess in Abbildung 5 zeigt, holt "go build" einen ganzen Schwung an Libraries als Source-Code aus ihren Github-Repositories und bündelt alles in einem Binary, das mit 2.8MB gar nicht mal so dick ist.

Abbildung 5: Ein neu erzeugtes Go-Modul holt beim Build-Prozess automatisch den Source-Code benötigter Libraries von Github ab und kompiliert ihn gleich mit, um ein Rundum-Sorglos-Binary zu erzeugen.

Auf- und zuklappen

Listing 1 holt in Zeile 6 den Code der genutzten Terminal-UI-Library unter dem Kürzel ui in den Code. Deren Funktion Init() versetzt das Terminalfenster in Zeile 21 in den Grafikmodus und verzögert das ordnungsgemäße Zusammenfalten mit defer in Zeile 24 bis ans Ende des Hauptprogramms.

Die UI in den Abbildungen 4 und 5 besteht aus zwei übereinander liegenden Widgets vom Typ "Paragraph" aus der Termui-Widgets-Library. Der obere Absatz zeigt das Datum zum gesuchten Wochentag an, sowie am rechten Rand die Anzahl der richtig geratenen Tage in der Variablen wins. Der untere Absatz zeigt einen statischen String, der die Wochentage von Sonntag bis Samstag durch Newline-Zeichen getrennt anzeigt. Die Methode SetRect() setzt jeweils die Größe der Widgets in Zeilen und Spalten, in denen jeweils ein Zeichen Platz findet. Damit das UI-Framework die Fensterchen auch auf der Terminaloberfläche anzeigt, gibt sie der Aufruf von ui.Render() in Zeile 37 an den Rendering-Engine weiter. Damit steht die Oberfläche, und Zeile 39 öffnet mit dem Aufruf von ui.PollEvents() einen Channel, der UI-Ereignisse wie Tastatureingaben, Mausklicks oder Fensterverkleinerungen meldet. Dazu blockt Zeile 41 bis ein Event vorliegt und die nachfolgende Switch-Anweisung prüft, ob der User ctrl-c oder q gedrückt hat und damit das Programm abbrechen will.

Terminal mit Mauseingabe

Der Event MouseLeft kommt hoch, falls der User mit der Maus geklickt hat. Die dem Event beiliegende Payload gibt die Koordinaten an, an denen sich der Mauszeiger zum Zeitpunkt des Klicks befand. Zeile 47 muss dazu den als generischen interface{}-Typ empfangenen Event dynamisch in den Typ ui.Mouse umwandeln und greift anschließend mit Y auf den (vertikalen) Y-Wert der Klickposition zu. Die Funktion wdayPick() ab Zeile 86 ermittelt daraus den geklickten Wochentag, denn das Programm weiß wegen dem von ihm selbst konstruierten UI-Layout, dass sich der Sonntag auf der Klickposition mit dem Y-Wert 4 befindet, Montag auf der 5, bis zum Samstag auf der Position 10. Andere Positionen verwirft die Funktion und gibt einen Fehler zurück, damit das Hauptprogramm den Klick ignoriert.

Hat der User richtig gerechnet und den korrekten Wochentag ausgewählt, ist die Bedingung in Zeile 53 wahr und das Attribut BorderStyle.Fg setzt den Rahmen des Wochentags-Widgets auf Grün. Der anschließende Aufruf von randDate() in Zeile 56 holt eine neue Aufgabe und der Erfolgszähler wins wird um Eins hochgesetzt. Hat der User hingegen falsch geraten, färbt Zeile 59 den Rand des Fensters rot und setzt wins zurück auf Null. Strafe muss sein.

Damit die UI den Erfolgszähler auch grafisch auffrischt, ändert der Aufruf von displayTask() in Zeile 63 den Inhalt des Widgets und der Aufruf ui.Render() mit beiden Widgets als Parametern bringt den aktuellen Stand im Terminal zur Anzeige. Damit der rote oder grüne Rand zur Erfolgsrückmeldung an den User aber nur kurz erscheint und dann sofort wieder verschwindet, startet Zeile 65 eine parallel laufende Go-Routine, die erst mit time.After() 200 Millisekunden schläft, dann den Rand des Fensters wieder auf das ursprüngliche White zurücksetzt (was eigentlich grau aussieht) und mit ui.Render() das Ganze auch wirklich anzeigt. So kommt Dynamik ins Spiel, ganz einfach, dank der in Go standardmäßig eingebauten Nebenläufigkeit.

Text in Farbe

Auch den Text in den Paragraph-Fenstern färbt termui auf Wunsch ein, allerdings nicht mit einem Attribut wie bei den Widget-Rändern, sondern durch spezielle Tags im dargestellten Text. Damit das zu errechnende Datum in schwarz und die Anzahl der erfolgreich geratenen Aufgaben in grün erscheinen, bauen die Zeilen 80-81 mit (fg:black) und (fg:green) entsprechende Farbbefehle in den darzustellenden Text ein.

Listing 1: dateday.go

    001 package main
    002 
    003 import (
    004   "errors"
    005   "fmt"
    006   ui "github.com/gizak/termui/v3"
    007   "github.com/gizak/termui/v3/widgets"
    008   "math/rand"
    009   "strings"
    010   "time"
    011 )
    012 
    013 var wdays = []string{"Sunday", "Monday",
    014   "Tuesday", "Wednesday", "Thursday",
    015   "Friday", "Saturday"}
    016 
    017 func main() {
    018   year := time.Now().Year()
    019   wins := 0
    020 
    021   if err := ui.Init(); err != nil {
    022     panic(err)
    023   }
    024   defer ui.Close()
    025 
    026   task := randDate(year)
    027 
    028   p := widgets.NewParagraph()
    029   p.SetRect(0, 0, 25, 3)
    030   displayTask(task, wins, p)
    031 
    032   days := widgets.NewParagraph()
    033   days.Text = fmt.Sprintf(
    034     "[%s](fg:black)",
    035     strings.Join(wdays, "\n"))
    036   days.SetRect(0, 3, 25, 12)
    037   ui.Render(p, days)
    038 
    039   uiEvents := ui.PollEvents()
    040   for {
    041     e := <-uiEvents
    042     switch e.ID {
    043     case "q", "<C-c>":
    044       return
    045     case "<MouseLeft>":
    046       wdayGuess, err := wdayPick(
    047         e.Payload.(ui.Mouse).Y)
    048       if err != nil { // invalid click?
    049         continue
    050       }
    051       wdayName := wdays[task.Weekday()]
    052 
    053       if wdayGuess == wdayName {
    054         days.BorderStyle.Fg =
    055 	  ui.ColorGreen
    056         task = randDate(year)
    057         wins++
    058       } else {
    059         days.BorderStyle.Fg = ui.ColorRed
    060         wins = 0
    061       }
    062 
    063       displayTask(task, wins, p)
    064       ui.Render(p, days)
    065       go func() {
    066         <-time.After(
    067 	    200 * time.Millisecond)
    068         days.BorderStyle.Fg =
    069 	  ui.ColorWhite
    070         ui.Render(days)
    071       }()
    072     }
    073   }
    074 }
    075 
    076 func displayTask(task time.Time,
    077   wins int, widget *widgets.Paragraph) {
    078 
    079   widget.Text = fmt.Sprintf(
    080     "[%d-%02d-%02d](fg:black)" +
    081     "%s[%3d](fg:green)",
    082     task.Year(), task.Month(), task.Day(),
    083     "         ", wins)
    084 }
    085 
    086 func wdayPick(y int) (string, error) {
    087   if y > 10 || y < 4 {
    088     return "", errors.New("Invalid pick")
    089   }
    090   return wdays[y-4], nil
    091 }
    092 
    093 func randDate(year int) time.Time {
    094   start := time.Date(year, time.Month(1),
    095     1, 0, 0, 0, 0, time.Local)
    096   end := start.AddDate(1, 0, 0)
    097 
    098   s1 := rand.NewSource( // random seed
    099     time.Now().UnixNano())
    100   r1 := rand.New(s1)
    101 
    102   epoch := start.Unix() + int64(r1.Intn(
    103     int(end.Unix()-start.Unix())))
    104   return time.Unix(epoch, 0)
    105 }

Gefrett Datumsarithmetik

Einen zufälliges Tag eines Jahres auszuwählen ist schwieriger als man zunächst annimmt. Klar, die meisten Jahre haben 365 Tage, aber in einem Schaltjahr mit 366 Tagen sollte auch der 29. Februar hin und wieder drankommen. Go bietet mit dem Duration-Typ aus dem Paket time ein Verfahren, die Zeitspanne zwischen dem 1. Januar des untersuchten Jahres und dem gleichen Datum des Folgejahres auszurechnen, weigert sich allerdings, dies in Tagen auszudrücken, sondern beschränkt sich auf Stunden. Der Grund dafür ist das gräßliche Gefrett, das entsteht, wenn zwischen zwei Datumsangaben eine Schaltsekunde ([5]) oder die Sommerzeitumstellung liegt. Zählt die dann als Bruchteil eines Tages oder nicht? Go zwingt den Programmierer zur Multiplikation der Stundendifferenz mit 24, um auf die Tageszahl zu kommen, als Hinweis darauf, dass dies unter Umständen nicht ganz stimmt.

Einfacher geht es mit der unter Unix üblichen Unixzeit, die die verstrichenen Sekunden seit dem 1. Januar 1970 angibt, aufgetretene Schaltsekunden aber nicht mit einbezieht, sondern die Zeitpunkte vor und während einer Schaltsekunde mit dem gleichen Zeitstempel quittiert. Die Funktion randDate() ab Zeile 93 in Listing 1 bestimmt die Unixzeit des ersten Januars des untersuchten Jahres, sowie den Zeitstempel des 1. Januars des Folgejahrs und ermittelt die Differenz in Sekunden. Anschließend wählt die Funktion rand.Intn() aus dem Paket math/rand eine Zufallszahl, die zwischen 0 (einschließlich) und dem Zeitstempel des 1. Januars des Folgejahres (ausschließlich) liegt und addiert den Wert zum Startdatum. Heraus kommt ein Sekundenstempel irgendwann im aktuellen Jahr, den die Funktion time.Unix() in ein time.Time-Objekt aus der Go-Standardbibliothek umrechnet, dessen Monat und Tag mit Month() und Day() herauspurzeln. Da Unix-Sekunden als int64 vorliegen und die Zufallsfunktionen aus math/rand normale ints mögen und liefern, muss Zeile 102 zwischen beiden vermitteln und die Typen entsprechend umbiegen. Der von Go bereitgestellte Zufallsgenerator hat noch die unangenehme Eigenschaft, bei jedem neuen Aufruf des Programms die gleichen Zufallswerte zu liefern, was auf Dauer keinen nachhaltigen Trainingserfolg bringt. Aus diesem Grund zapft Zeile 98 eine neue Entropiequelle an, initialisiert sie mit der aktuellen Uhrzeit in Nanosekunden als "Seed" und ruft damit den neuen Zufallsgenerator in der Variablen r1 ins Leben. Dessen Methode Intn() reagiert deshalb bei jedem neuen Aufruf des Binaries dateday mit neuen Zufallssequenzen.

Bühnenstress unter Zeitdruck

Fertig ist der Lack und das Trainingsprogramm kann beginnen! Fortgeschrittene dürfen gerne auch mehrere wechselnde Jahre mit verschiedenen Doomsdays einprogrammieren. Wer den Zeitdruck des Magiers vor ungeduldigen Zuschauern simulieren möchte, kann als zusätzliche Schikane ins Spiel einen Fortschrittsbalken ([4]) einbauen, der dem Kandidaten nur eine kurze Zeitspanne lässt, um den Wochentag auszuwählen. Verstreicht die Zeit ohne Aktion, sackt der Spielstand auf null Punkte ab, vielleicht noch untermalt von einer gnadenlosen Gameshow-Tröte als abgespielter Sounddatei.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/08/snapshot/

[2]

"Doomsday-Methode", Wikipedia, https://de.wikipedia.org/wiki/Doomsday-Methode

[3]

Michael Schilli, "Datumsarithmetik": Linux-Magazin 12/07, S.114, https://www.linux-magazin.de/ausgaben/2007/12/datumsarithmetik/

[4]

Michael Schilli, "Fortschritt auf Raten": Linux-Magazin 12/18, S.104, https://www.linux-magazin.de/ausgaben/2018/12/snapshot-9/

[5]

Schaltsekunde, Wikipedia, https://de.wikipedia.org/wiki/Schaltsekunde

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc