Dienstag, 17. Dezember 2013

Eine Kalkulation umkehren

Ein Kalkulationsschema ist die sukzessive Anwendung von Konditionen auf einen Grundpreis. In der Regel ist jede dieser Konditionen entweder ein absoluter Betrag, oder sie ergibt sich aus einer vorherigen Zwischensumme durch Anwendung eines Prozentsatzes.

Ein Schema kann demnach als Liste von Funktionen notiert werden - in JavaScript stellt beispielsweise diese Liste

var conditions = [
  proz( 7 ), 
  abs(5), 
  proz_base(10), 
  abs(3), 
  proz(13)
  ]
ein Kalkulationsschema dar. Jedes einzelne Element der Liste conditions ist eine Funktion - ein absoluter oder relativer Zuschlag. Dabei steht abs() für absolute Zuschläge:
function abs(q) {
  return function(x) {
    return x + q 
    }
  }
abs() ist eine Funktion, die Funktionen produziert. Die Funktion abs(5) beispielsweise ist die Vorschrift, zu einem gegebenen Wert die Zahl 5 zu addieren.[1]

Eine Funktion zur Erzeugung prozentualer Konditionen schreibt sich analog

function proz(p) { 
  return function(x) {
    return x*(1+p/100)
    }
  }
In der Regel wird eine Kondition in einem Schema auf das vorhergehende Zwischenergebnis angewendet. Es kann jedoch auch Konditionen geben, die eine Funktion des Grundpreises sind, mit dem man ins Kalkulationsschema eingestiegen ist. Wenn wir diesen Grundpreis als zweites Argument base an die Funktion übergeben, können wir prozentuale Grundpreiskonditionen wie folgt notieren:
function proz_base(p) {
  return function(x,base) {
    return x+base*p/100
    }
  }
Der Vorgang, ein Kalkulationsschema auf einen Basispreis anzuwenden, ist ein klassisches Beispiel für die funktionale reduce-Operation von Arrays:
var result = conditions.reduce( 
  function(result,cond) {
    return cond(result,base)
    }, 
    base )
Beginnend mit base als Initialwert, wird durch reduce() sukzessive für jedes Element der conditions-Liste die hier angegebene anonyme Funktion function(result,cond) angewendet, wobei das Argument result das jeweils zuletzt berechnete Zwischenergebnis und cond das aktuelle Listenelement (die Kondition) enthält.

Wie fast alles im Leben eines Kaufmanns, übersteigt auch dies nicht den Horizont der vier Grundrechenarten. Auch nach Anwendung mehrerer Konditionen bleibt der Endpreis letztlich eine lineare Funktion des Einstiegspreises. Es gibt also Konstanten a und b, die sich aus den Parametern der Konditionsfunktionen berechnen lassen, so dass gilt:

result = F(base) = a * base + b
Offensichtlich lassen sich die Koeffizienten a und b aus F und ihrer Ableitung F' schreiben – als
a = F'
b = F(0)
Nun definiert die Ableitung F' wieder ein Kalkulationsschema. Es ergibt sich aus dem Kalkulationsschema F durch Weglassen aller absoluten Zeilen. Wenn wir dieses das reduzierte Schema nennen und es programmatisch definieren wollen, so müssen wir die Klasse der absoluten Konditionen besonders kennzeichnen, z.B. mit einem Flag isAbsolute:
function abs(q) { 
  var f = function(x) {
    return x+q
    }
  f.isAbsolute = true
  return f
  }
Das reduzierte Schema ergibt sich dann aus dem gegebenen Schema durch Anwendung der Filterfunktion:
var reducedSchema = conditions.filter( isRelative );
    
function isRelative( cond ) {
  return ! cond.isAbsolute
  }
Wenn wir diese Funktionen nun noch in einer Funktion schema() kapseln, so können wir darin die obige Lösungsformel für die Umkehrung der Kalkulation implementieren:
function schema( ) {
  var conditions = convertToArray( arguments );
  return {
// "compute" applies the schema to a base price
      compute:compute,
// "resolve" finds the base price yielding the specified result        
      resolve:function(result) {
        return ( result - compute(0) ) / reducedSchema().compute(1) 
        }
      }
      
  function compute(base) {
    return conditions.reduce( function(result,cond) {
      return cond(result,base)
      }, 
      base )
    }
    
  function reducedSchema() {
   return schema.apply( null, conditions.filter( isRelative ) )
    }  
    
  function isRelative( cond ) {
   return ! cond.isAbsolute
   }
   
  }

// Auxiliary function to convert an array-like object into a real Array instance
function convertToArray( arraylikeThing ) {
  return Array.prototype.slice.apply( arraylikeThing )
  }
Hier ist die Funktion resolve also die Umkehrung von compute, die interessanterweise selbst durch zwei Aufrufe von compute, also durch zwei Vorwärtsanwendungen eines Schemas, definiert werden kann.[2]
resolve:function(result) {
  return ( result - compute(0) ) / reducedSchema().compute(1) 
  }
Für ein Schema, das nun definiert wird, indem seine Konditionen der Reihe nach als Argumente übergeben werden,
// Test 
var s = schema( 
 proz( 7 ), 
 abs(5), 
 proz_base(10), 
 abs(3), 
 proz(13) 
 )
lässt sich dann verifizieren, dass resolve wirklich die Umkehrfunktion von compute ist. Beispielsweise liefert
s.resolve( s.compute( 20 ) )
wieder 20 zurück - den Grundpreis, mit dem man gestartet ist.

Das vollständige Code-Beispiel zum Ausführen oder Modifizieren habe ich hier in JavaScript – und im Vergleich hier in Haskell notiert.

Ein kleiner Unterschied in der Implementierung ist noch, dass die Haskell-Version auch Konditionen erlaubt, die auf beliebige Zwischensummen statt nur auf dem Grundpreis oder dem zuletzt errechneten Preis operieren.

Das beschriebene Verfahren funktioniert auch in solchen allgemeinen Fällen, wie ich hier ausführlich begründet habe.

Kalkulationsschemata enthalten im echten Leben natürlich noch einige weitere Komplikationen. So kann es gestaffelte Konditionen geben: Rabatte oder Zuschläge, die abhängig vom Bezugspreis verschieden hoch ausfallen. In diesen Fällen lässt sich das Verfahren retten, indem man für jede Entscheidung ein eigenes Kalkulationsschema bildet, jedes einzelne zurückrechnet und dann prüft, für welchen Zweig die Vorbedingung der Kondition erfüllt ist.[3]



[1] Dies ist ein Beispiel für die Implementierung von Currying in JavaScript. Der Rückgabewert von abs() ist nicht nur eine Referenz auf die blosse Funktion function(x){ return q+x }, sondern eine Closure, die nicht nur die Funktion selbst, sondern auch die lokalen Variablen und Aufrufparameter zum Zeitpunkt des Verlassens der umgebenden Funktion aufbewahrt (hier: den Wert von q).

[2] Interessant ist, dass die Ausdrücke compute(0) und reducedSchema().compute(1) gar nicht vom aktuellen Aufrufparameter abhängen. Wenn man für dasselbe Schema häufig resolve() aufrufen muss, lässt sich die Effizienz der Funktion verbessern, indem man diese beiden Werte als Closure-Parameter puffert. Sie lassen sich auch für den Direktweg, also für die compute()-Funktion nutzen.

[3] Dieses Problem hat man aber nur mit Konditionen, die preisabhängige Bedingungen oder Staffeln enthalten. Die weitaus häufigeren mengenabhängigen Staffeln machen dagegen keine Probleme, da die Menge ja einen unabhängigen Parameter darstellt (man kann die Menge "gegeben, aber fest" lassen, d.h. das Schema in der jeweils gegebenen Menge vor- und zurückrechnen).

Keine Kommentare :