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.
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) |
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. |
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.
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.
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.
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 }
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 int
s 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.
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.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/08/snapshot/
"Doomsday-Methode", Wikipedia, https://de.wikipedia.org/wiki/Doomsday-Methode
Michael Schilli, "Datumsarithmetik": Linux-Magazin 12/07, S.114, https://www.linux-magazin.de/ausgaben/2007/12/datumsarithmetik/
Michael Schilli, "Fortschritt auf Raten": Linux-Magazin 12/18, S.104, https://www.linux-magazin.de/ausgaben/2018/12/snapshot-9/
Schaltsekunde, Wikipedia, https://de.wikipedia.org/wiki/Schaltsekunde
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc