Sonntag, 23. Mai 2010

Excel als Benutzerschnittstelle

Mit der Einführung von VisiCalc, der ersten Software für Tabellenkalkulation, mauserte sich 1979 der Apple II vom blossen Heim- und Hobbycomputer zum Geschäftsrechner. Die einfache Darstellbarkeit von Daten und ihren Abhängigkeiten in einer änderbaren Tabelle brachte den Erfolg! Bis heute ist die Tabellenkalkulation ein Grundbedürfnis von vielen beruflichen PC-Anwendern. Ein Spreadsheet kann man per Mail herumschicken, in seine Präsentation oder Spezifikation einbauen, vor allem kann man Daten ändern, um die gegenseitige Abhängigkeit der Werte zu untersuchen. Daten können graphisch in Diagrammen dargestellt und mit einem simplen Format [1] via Zwischenablage in andere Applikationen weitergereicht werden. Das subjektive Erlebnis für den Benutzer ist, dass er alles im Griff hat. Das fühlt sich gut an und verlangt nach Wiederholung. Die "Domänenexperten", mit denen Entwickler in der Rolle von Auftraggebern, Anwendern und Testern zu tun haben, schwören auf Excel.

Natürlich lässt sich Excel auch missbrauchen: "Wenn du einen Hammer hast, sieht alles wie ein Nagel aus". Wer Excel kennengelernt und verstanden hat, ist in der Versuchung, es für alles zu verwenden, was ihm unter die Finger kommt. Das Haushaltskonto als Excel-Sheet mag ja noch angehen - aber für das Hauptbuch des Unternehmens verwendet man doch lieber etwas anderes. Auch die Projektplanung mit eingefärbten Zellen für die Kalenderwochen sieht eigentümlich unprofessionell aus. Wir haben auch Anwender, die Excel als Datenbankersatz benutzen, wobei sie früher häufig an die Grenze von 65.536 Zeilen pro Datenblatt stiessen (eine Grenze gibt es immer noch, aber sie liegt nun bei 1.048.576 Zeilen - das gibt Raum für viele neue verrückte Missbrauchsideen).

Weitblickend hatte Charles Simonyi, damals Entwickler bei Microsoft, das Potential der Tabellenkalkulation als universeller Benutzungsschnittstelle erkannt, als er die Ideen von VisiCalc aufgriff und in Microsofts MultiPlan und danach in Excel umsetzte. Heute versucht er mit einer eigenen Firma, seine Vision von Intentional Software Gestalt werden zu lassen. Auch hierbei spielt Tabellenkalkulation wieder eine grosse Rolle, indem sie als Regeleditor zur Metaprogrammierung verwendet werden kann. Wer meinen Blog über Software-Steinzeit gelesen hat, weiss, wohin die Reise geht. Die von Simonyis Team entwickelte und in den ersten Unternehmen bereits produktiv eingesetzte Intentional Workbench ist ein Meilenstein auf dem Wege zu besserer, robusterer, kompakterer und lesbarerer Software.

Doch diese Perspektiven wären einen eigenen Blog wert. Heute will ich über die Verwendung von Excel als Benutzerschnittstelle in meinem Projekt AstroPatterns schreiben.

astropatterns ist eine Bibliothek mit zeitkritischen Funktionen, die nach astrologischen Konstellationen oder Mustern sucht. Dabei werden unter Umständen Millionen von Planetenberechnungen ausgeführt. Um keine Zeit an überflüssige Layer wie .NET zu verschenken, habe ich mich für die Implementierung mit C++ entschieden, wobei ich den GNU Compiler g++ zur Erzeugung der Bibliothek verwende. Bei der Implementierung der zentralen Akteure von astropatterns konnte ich stark von der Objektorientierung profitieren – um der vielseitigen Anwendbarkeit willen sind die öffentlichen Funktionen der Bibliothek aber als C-Funktionen definiert. Im wesentlichen bin ich damit plattformunabhängig, auch wenn ich selbst die Software nur auf einem Windows-Rechner compiliere und teste. Dabei entsteht eine Bibliothek astropatterns.dll. Die Bibliothek verwendet ihrerseits nur die Ephemeridensoftware Swiss Ephemeris, die als dynamische Bibliothek zur Laufzeit herangezogen wird. Das ist die einzige Abhängigkeit. Mit dem API von astropatterns kann man Konstellationen definieren und Berechnungen durchführen, deren Ergebnisse in Form von Arrays oder Strukturen zurückgeliefert werden. astropatterns selbst hat weder eine Benutzerschnittstelle noch irgendein I/O.

Um eine Benutzerschnittstelle anzubinden, fiel meine Wahl auf Excel. Die Möglichkeit, auf verschiedene Blätter verteilt, Einstellungen und Regeln definieren zu können, war genau das, was ich brauchte. Das erste Tabellenblatt fungiert dabei als Arbeitsbereich, in dem die Funktionen von astropatterns aufgerufen und die Ergebnisse - Termine, zu denen die gesuchten Konstellationen auftraten - als Liste angezeigt werden:



Ich muss vorausschicken, dass die Programmiersprache Visual Basic (ganz gleich in welcher Abart, sei es VBscript oder VBA) für mich zu den hässlichsten Sprachen überhaupt gehört (sie wird an Hässlichkeit vielleicht nur noch durch die Programmiersprache Brainfuck übertroffen, die aber nur eine Spielerei von Informatikstudenten darstellt). Dennoch habe ich für dieses Projekt in den sauren Apfel gebissen und ein paar Zeilen VBA geschrieben, um die DLL in das Excelsheet einzubinden.

Einige Dinge lohnt es sich dabei für ähnliche Projekte festzuhalten:

  • Excel kennt das eigene Verzeichnis nicht.
    Das ist am Anfang etwas verwirrend: Wenn ich eine DLL zusammen mit dem Excel-Dokument, das sie aufrufen soll, in einem Ordner habe, findet Excel diese DLL nicht. Sollen dynamische Bibliotheken neben den in der Umgebungsvariable PATH aufgeführten Verzeichnissen nicht auch im laufenden Verzeichnis gesucht werden? Ja, das ist so. Aber wenn man Excel startet, wird tückischerweise das "laufende Verzeichnis" auf das Verzeichnis "Eigene Dateien" umgeschossen, das im Windows-Benutzerkonzept für die Ablage eigener Dateien vorgesehen ist – ganz egal, wo sich das gerade geöffnete Excel-Dokument befindet. Dagegen hilft nur, zum Zeitpunkt Workbook_Open das laufende Verzeichnis durch Aufruf der entsprechenden Windows-API-Funktion wieder auf den Wert zu setzen, den ein unbefangener Mensch als das laufende Verzeichnis erwarten würde:
    Private Sub Workbook_Open()
    ' Set the current directory to the location
    ' where the Excel sheet is stored
    ' This enables the VBA code to find
    ' the astropatterns.dll and swedll32.dll there
    SetCurrentDirectory (ThisWorkbook.path)
    End Sub

    Die Funktion SetCurrentDirectory aus kernel32.dll wird dabei folgendermassen deklariert:
    ' SetCurrentDirectory
    ' Needed since Excel uses "My Documents"
    ' as current directory by default
    ' It should use the path of the workbook
    Public Declare Function SetCurrentDirectory _
    Lib "kernel32" _
    Alias "SetCurrentDirectoryA" ( _
    ByVal lpPathName As String _
    ) As Long

  • VBA verlangt stdcall.
    gcc erzeugt sogenannte "dekorierte" Funktionen mit dieser Aufrufkonvention. Die "Dekorierung" besteht in einem angehängten @, gefolgt von der Anzahl Bytes, die die Parameterliste insgesamt auf dem Stack konsumiert. Wenn man die Funktionen VBA bekanntmacht, muss man diese dekorierten Funktionen verwenden. Damit der Aufruf problemlos möglich ist, muss die Funktion in C wie folgt deklariert werden
    extern "C" __declspec (dllexport) 
    void __stdcall ap_set_mundane_positions(
    const t_double_pair* data,
    const size_t size );

    und kann dann in VBA mit folgender Deklaration bekanntgemacht werden:
    Public Declare Function ap_set_mundane_positions _
    Lib "astropatterns.dll" _
    Alias "ap_set_mundane_positions@8" ( _
    ByRef data As Double, _
    ByVal size As Long _
    ) As Long

    Damit ich keinen Schreibkrampf bekomme, habe ich für die Definition der öffentlichen Schnittstelle in C ein Macro APIFUNC definiert:
    #define APIFUNC(x) extern "C" __declspec (dllexport) x __stdcall

    Das Argument der Macrofunktion nimmt den Typ des Rückgabewerts entgegen. Somit schreibe ich die obige Funktion in Wirklichkeit etwas kompakter:
    APIFUNC( void ) ap_set_mundane_positions( 
    const t_double_pair* data,
    const size_t size );

  • Nur in Modulen geschriebener Code ist wiederverwendbar.

    Vorsicht vor den Codeblöcken, die VBA unter "Microsoft Excel Objekte" anbietet. Was man dort implementiert, ist nicht übergreifend verwendbar. Unter "Microsoft Excel Objekte" sollten nur Ereignisbehandler und direkte Zugriffe auf die Tabellendaten hinterlegt werden. Die eigentliche Logik sollte dann als "Modul" implementiert werden, da sie sonst nicht übergreifend verfügbar ist.

  • Übergabe von nullterminierten Strings.

    Es ist möglich, einen gewöhnlichen C-String, genauer einen nullterminierten Array von Charactern, per Referenz von der Schnittstelle entgegenzunehmen und im VBA weiterzuverwenden. Nehmen wir an, die Funktion ap_get_log() gebe die Referenz auf einen C-String zurück. Dann können wir die Zeichenkette mit der Funktion lstrCpy des Windows-Kernels in einen VB-String kopieren, nachdem wir mit der Space$()-Funktion von VBA zunächst Platz für das Ergebnis geschaffen haben:
    'Determine the length of a \0 terminated string
    Public Declare Function lstrLen _
    Lib "kernel32" _
    Alias "lstrlenA" _
    (ByVal lpString As Long) As Long

    'Copy a string
    Public Declare Function lstrCpy _
    Lib "kernel32" _
    Alias "lstrcpyA" _
    (ByVal lpString1 As String, _
    ByVal lpString2 As Long) As Long

    Public Function cstar_to_string(lpString As Long) As String

    cstar_to_string = Space$(lstrLen(ByVal lpString))
    lstrCpy ByVal cstar_to_string, ByVal lpString

    End Function

    In der umgekehrten Richtung - von VBA in C - ist es einfacher: Man übergibt einen String ohne Umschweife an einen Parameter, der in der Deklaration der DLL-Funktion als ByVal ... As String deklariert ist. Auf der anderen Seite kommt dann der gewünschte char* an. [2]

  • Übergabe von Datenstrukturen und Arrays

    Flache Datenstrukturen können in beide Richtungen übergeben werden, wenn sie auf die gleiche Weise deklariert werden. Beispiel: Den Typ t_horo, der die drei für die Berechnung eines Horoskops nötigen Zahlen enthält,
    typedef struct {
    double jd_et;
    double lon;
    double lat;
    } t_horo;

    notiere ich analog in VBA wie folgt:
    Public Type t_horo
    jd_et As Double
    lon As Double
    lat As Double
    End Type

    Schwieriger sieht es mit Arrays aus: Ein Array wird von VBA leider nicht so übergeben, wie man es erwarten würde - als einfache Abfolge der einzelnen Records. Einen VBA-Array mit benutzerdefiniertem Zeilentyp zu übergeben, ist möglich, erfodert jedoch einen meist unangemessenem Aufwand: Man müsste hierfür das C/C++ Programm verändern und ihm die VBA-Konvention für solche Arrays beibringen (siehe KB 194609 im MSDN). Diesen Aufwand kann man sich sparen, indem man den Array entweder satzweise in einer Schleife übergibt (bei kleineren Arrays) oder in ein einfach zu analysierendes Zwischenformat wandelt (z.B. csv) und dann als String übergibt.

  • Ein Tabellenblatt mit Selbsttests schreiben!

    Dies ist schon fast eine selbstverständliche Bemerkung: Code sollte, wo immer möglich und sinnvoll, durch automatische Tests abgesichert sein. Ein Testframework in Excel hat man schnell hingeschrieben. Es empfiehlt sich, ein eigenes Worksheet für die Selbsttests einzurichten und dort zeilenweise die Namen der Testfunktionen und die jeweils erwarteten Ergebnisse zu hinterlegen. Mit einem Button "Do Tests" können dann all diese Testfunktionen ausgeführt werden. Es schadet nichts, dieses Sheet mit auszuliefern. In der Wartung wird es sich ziemlich sicher bewähren. Bei der Implementierung nutzte ich den dynamischen Funktionsaufruf mit der Funktion CallByName(), wie ich es schon bei meiner Excel-Oberfläche für die Swiss Ephemeris getan hatte.


Alles in allem, bin ich bei der Kombination von Excel für die Benutzerschnittstelle mit C++ für die eigentliche Programmlogik bislang noch auf kein unlösbares Problem gestossen. Manches würde ich mir in der Umsetzung einfacher wünschen, und eine schönere Programmiersprache als ausgerechnet VBA wäre auch angenehm. Aber es geht alles, und für die Umsetzung eines Projektes wie astropatterns, dessen Hauptlast auf den Bibliotheksfunktionen selbst liegt, bin ich mit meiner Entscheidung für Excel als Benutzerschnittstelle zufrieden.

[1] Das Datenaustauschformat von Tabellenkalkulationsprogrammen für die Zwischenablage ist meist eine Einfachversion von CSV mit TAB als Trennzeichen.
[2] In .NET muss man hier etwas anders vorgehen: Man verwendet in der Funktionsdeklaration eine Referenz auf die Klasse System.Text.StringBuilder (statt auf String) und erhält dann nach Ausführung der externen Funktion einen im VBA-Programm weiterverwendbaren String durch Anwendung der Methode toString() auf diese Referenz.

Keine Kommentare :