Zum Inhalt springen

tuerchen 07 · 7. Dezember

Kein Cent geht verloren

Code-AufgabeMittelPHP 8.4

Aufgabe

Die WG teilt die Rechnung vom Weihnachtsmarkt. Baue dafür den Wert final readonly class Money (PHP 8.2+ readonly class: alle Eigenschaften sind automatisch readonly).

  • __construct(int $cents, string $currency) mit öffentlichen Eigenschaften $cents und $currency. Die Währung besteht aus genau drei Großbuchstaben A–Z, sonst InvalidArgumentException.
  • add(Money $other): Money liefert ein neues Objekt mit der Summe. Andere Währung → DomainException.
  • allocate(int ...$ratios): array verteilt den Betrag im Verhältnis der Anteile und liefert eine Liste neuer Money-Objekte in der Reihenfolge der Anteile:
    1. Jeder Teil bekommt zuerst intdiv($cents * $anteil, $summeDerAnteile) (Ganzzahldivision, schneidet Richtung 0 ab).
    2. Den Rest verteilst du cent-weise von vorne: je Teil höchstens einen Cent (bei negativem Betrag minus einen Cent). Teile mit Anteil 0 bekommen nie etwas und werden übersprungen.
    3. Die Summe der Teile ist immer exakt der Ausgangsbetrag.
    4. Keine Anteile, ein negativer Anteil oder Summe 0 → InvalidArgumentException.
  • __toString(): deutsches Format mit Tausenderpunkt, z. B. -1.234,05 EUR.

Beträge bleiben in den Tests unter einer Billion Cent.

Sichtbare Tests

  • 100 Cent auf drei gleiche Teile

    $c = fn (array $parts) => implode(',', array_map(fn (Money $m) => $m->cents, $parts));
    echo $c((new Money(100, 'EUR'))->allocate(1, 1, 1));

    Erwartet: 34,33,33

  • Addieren und Anzeigen

    $a = new Money(123400, 'EUR');
    $b = $a->add(new Money(5, 'EUR'));
    echo $a . ' | ' . $b;

    Erwartet: 1.234,00 EUR | 1.234,05 EUR

  • Ungültige Währung

    try {
        new Money(1, 'eur');
        echo 'keine Ausnahme';
    } catch (InvalidArgumentException $e) {
        echo 'InvalidArgumentException';
    }

    Erwartet: InvalidArgumentException

Dazu kommen 10 versteckte Tests, die nur beim Abgeben laufen.

Tipp anzeigenkostet keine Punkte

Rechne nur mit Cent als int, nie mit Floats. intdiv() und array_sum() helfen dir, den Rest zu finden.

Extra-Stufe (+5 Punkte, freiwillig)

Extra-Stufe (+5):

  • allocateFair(int ...$ratios): array verteilt wie allocate(), aber den Rest nach dem größten Bruchteil (Hare-Niemeyer): Den nächsten Cent bekommt der Teil mit dem größten Divisionsrest abs($cents * $anteil % $summe); bei Gleichstand der weiter vorne stehende. Anteile 0 bekommen nichts, gleiche Fehlerfälle wie bei allocate().
  • static parse(string $text): Money liest genau das Format von __toString() zurück (-1.234,05 EUR, Tausenderpunkte nur korrekt gesetzt, keine führenden Nullen außer 0,xx). Alles andere → InvalidArgumentException.

Kein zweiter Editor: Dein Code oben zählt für beides. Die Extra-Tests laufen mit jeder Abgabe (5 Tests).

Mitraten geht mit deinem kostenlosen Entwicklerkonto

Lesen darf jeder. Punkte, Bestenliste und Preise gibt es nur mit Konto.

Auflösung

Geld ist ein Paradebeispiel für Value Objects: Eine readonly class stellt sicher, dass sich ein Betrag nach dem Erzeugen nie mehr ändert. Jede Rechnung liefert ein neues Objekt.

Die Verteilung ist der knifflige Teil:

  • Wer mit Floats rechnet ($cents * $r / $total plus round()), verliert oder erfindet Cents.
  • floor() statt intdiv() rundet negative Beträge in die falsche Richtung: −100 auf drei Teile wird dann −34, −34, −34 – und der Rest positiv.
  • Der Rest darf nicht an Teile mit Anteil 0 gehen. Wer „immer ab Index 0“ verteilt, schenkt dem Mitbewohner, der gar nichts getrunken hat, einen Cent.
  • Bonusfalle: /^[A-Z]{3}$/ lässt "EUR\n" durch, weil $ auch vor einem abschließenden Zeilenumbruch passt. Richtig ist \z oder der Modifier D.

Doku: readonly-Klassen, intdiv.

Musterlösung

<?php

final readonly class Money
{
    public function __construct(public int $cents, public string $currency)
    {
        if (!preg_match('/^[A-Z]{3}\z/', $currency)) {
            throw new InvalidArgumentException("Ungültige Währung: {$currency}");
        }
    }

    public function add(Money $other): Money
    {
        if ($other->currency !== $this->currency) {
            throw new DomainException('Währungen passen nicht zusammen.');
        }

        return new Money($this->cents + $other->cents, $this->currency);
    }

    /** @return list<Money> */
    public function allocate(int ...$ratios): array
    {
        if ($ratios === [] || min($ratios) < 0 || array_sum($ratios) === 0) {
            throw new InvalidArgumentException('Mindestens ein Anteil, keiner negativ, Summe größer 0.');
        }

        $total = array_sum($ratios);
        $shares = array_map(fn (int $r) => intdiv($this->cents * $r, $total), array_values($ratios));
        $rest = $this->cents - array_sum($shares);
        $step = $rest <=> 0;

        foreach (array_values($ratios) as $i => $ratio) {
            if ($rest === 0) {
                break;
            }
            if ($ratio > 0) {
                $shares[$i] += $step;
                $rest -= $step;
            }
        }

        return array_map(fn (int $c) => new Money($c, $this->currency), $shares);
    }

    /** @return list<Money> Rest nach größtem Bruchteil (Hare-Niemeyer) */
    public function allocateFair(int ...$ratios): array
    {
        if ($ratios === [] || min($ratios) < 0 || array_sum($ratios) === 0) {
            throw new InvalidArgumentException('Mindestens ein Anteil, keiner negativ, Summe größer 0.');
        }

        $ratios = array_values($ratios);
        $total = array_sum($ratios);
        $shares = [];
        $remainders = [];
        foreach ($ratios as $i => $ratio) {
            $shares[$i] = intdiv($this->cents * $ratio, $total);
            $remainders[$i] = abs(($this->cents * $ratio) % $total);
        }
        $rest = $this->cents - array_sum($shares);
        $step = $rest <=> 0;

        $order = array_keys($ratios);
        usort($order, fn (int $a, int $b) => [$remainders[$b], $a] <=> [$remainders[$a], $b]);
        foreach ($order as $i) {
            if ($rest === 0) {
                break;
            }
            if ($ratios[$i] > 0) {
                $shares[$i] += $step;
                $rest -= $step;
            }
        }

        return array_map(fn (int $c) => new Money($c, $this->currency), $shares);
    }

    public static function parse(string $text): Money
    {
        if (!preg_match('/^(-?)(0|[1-9]\\d{0,2}(?:\\.\\d{3})*),(\\d{2}) ([A-Z]{3})\\z/', $text, $m)
            || ($m[1] === '-' && $m[2] === '0' && $m[3] === '00')) {
            throw new InvalidArgumentException("Kein Geldbetrag: {$text}");
        }
        $cents = (int) (str_replace('.', '', $m[2]) . $m[3]);

        return new Money($m[1] === '-' ? -$cents : $cents, $m[4]);
    }

    public function __toString(): string
    {
        return number_format($this->cents / 100, 2, ',', '.') . ' ' . $this->currency;
    }
}

Zurück zum Kalender