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$centsund$currency. Die Währung besteht aus genau drei Großbuchstaben A–Z, sonstInvalidArgumentException.add(Money $other): Moneyliefert ein neues Objekt mit der Summe. Andere Währung →DomainException.allocate(int ...$ratios): arrayverteilt den Betrag im Verhältnis der Anteile und liefert eine Liste neuerMoney-Objekte in der Reihenfolge der Anteile:- Jeder Teil bekommt zuerst
intdiv($cents * $anteil, $summeDerAnteile)(Ganzzahldivision, schneidet Richtung 0 ab). - 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.
- Die Summe der Teile ist immer exakt der Ausgangsbetrag.
- Keine Anteile, ein negativer Anteil oder Summe 0 →
InvalidArgumentException.
- Jeder Teil bekommt zuerst
__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 anzeigenTipp ausblendenkostet 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): arrayverteilt wieallocate(), aber den Rest nach dem größten Bruchteil (Hare-Niemeyer): Den nächsten Cent bekommt der Teil mit dem größten Divisionsrestabs($cents * $anteil % $summe); bei Gleichstand der weiter vorne stehende. Anteile 0 bekommen nichts, gleiche Fehlerfälle wie beiallocate().static parse(string $text): Moneyliest genau das Format von__toString()zurück (-1.234,05 EUR, Tausenderpunkte nur korrekt gesetzt, keine führenden Nullen außer0,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 / $totalplusround()), verliert oder erfindet Cents. floor()stattintdiv()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\zoder der ModifierD.
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;
}
}