Perličky: přetěžování operátorů

29. 8. 2008
Doba čtení: 6 minut

Sdílet

Přetěžování operátorů je výsadou mnoha objektově orientovaných programovacích jazyků. Umožňují tak přirozeným způsobem pracovat zejména s numerickými objekty, které vlastní jazyk neimplementuje, jako jsou neomezeně přesná čísla, vektory, matice, atp. Dnes si ukážeme, kterak se přetěžuje v Perlu.

Intervalová reprezentace čísel

Jedním z pěkných a neohraných příkladů pro přetěžování operátorů je reprezentace čísel v pohyblivé řádové čárce pomocí intervalů. Jak je známo a často opakováno, reprezentace čísel v pohyblivé řádové čárce v počítači má konečnou přesnost. Navíc je tato reprezentace binární, tudíž je častým jevem, že tento neduh postihuje i čísla, která mají v desítkové soustavě konečný rozvoj. Programátoři neznalí těchto principů se můžou dočkat zajímavých překvapení:

$ perl -e '$a=1.7; $a+=0.2; print "oops, 1.7+0.2!=1.9\n" if $a!=1.9;'
oops, 1.7+0.2!=1.9

Protivné je navíc i to, že standardní konverze na řetězce zaokrouhluje na příliš málo platných cifer. Čísla tedy vypadají na první pohled stejně, ale přece se nerovnají.

$ perl -e '$a=1.7; $a+=0.2; print $a, "\n", 1.9, "\n";'
1.9
1.9
$ perl -e '$a=1.7; $a+=0.2; printf "%f\n%f\n", $a, 1.9;'
1.900000
1.900000

Až při bližším zkoumání se dovíme, proč tato situace nastala:

$ perl -e '$a=1.7; $a+=0.2; printf "%.65f\n%.65f\n", $a, 1.9;'
1.90000000000000000008673617379884035472059622406959533691406250000
1.89999999999999999997831595655028991131985094398260116577148437500

Jedním ze způsobů, jak tento problém ošetřit, nebo alespoň vizualizovat, je právě reprezentace pomocí intervalů. Namísto obyčejného čísla 1,9, které nelze v počítači přesně reprezentovat, pracujeme s intervalem, jehož meze přesně reprezentovatelné jsou, a zároveň naše číslo leží uvnitř tohoto intervalu. V případě čísla 1,9 by se (na mém PC) jednalo o interval <1,8999999999­999999998698957393017394­679191056638956069946289­0625; 1,90000000000­000000008673617379884035­472059622406959533691406­25>.

Intervaly budeme implementovat jako objekty, pro jednoduchost využijeme minimalistický generátor tříd Class::InsideOut. Stěžejní práci odvede funkce create_interval, která jednak najde minimum a maximum z předaných argumentů a jednak nalezne nejnižší vyšší, respektive nejvyšší nižší reprezentovatelné číslo k tomuto minimu, respektive maximu. Poznamenejme, že zvolený algoritmus nespočítá vždy minimální interval, ale na druhou stranu ne delší než druhý nejmenší interval, který obsahuje požadovaná čísla.

use strict;
use warnings;
package IntervalFloat;
use Class::InsideOut qw/:std/;
use List::Util qw/min max/;
sub create_interval {
        my @numbers = @_;
        my $min = min(@numbers);
        my $epsmin = abs $min;
        $epsmin /= 2.0 while ($min != $min - $epsmin / 2.0);
        my $max = max(@numbers);
        my $epsmax = abs $max;
        $epsmax /= 2.0 while ($max != $max + $epsmax / 2.0);
        return ($min - $epsmin, $max + $epsmax);
}
sub new {
        my ($class, @args) = @_;
        my $self = \(my $dummy);
        bless $self, $class;
        register($self);
        ($min_of{id $self}, $max_of{id $self}) = create_interval(@args);
        return $self;
}

Principy přetěžování operátorů

Přetěžování operátorů v Perlu umožňuje, jako obvykle, dělat jednoduché věci jednoduše a složité věci dělat aspoň nějak. Přetěžovat můžeme nejen aritmetické operátory, ale také vybrané unární aritmetické funkce a typové konverze na řetězec, číslo a booleovskou hodnotu. Začneme tedy poněkud zpátečnicky s přetížením konverze na řetězec, abychom mohli naše intervalové objekty vidět.

sub stringify {
        my $self = shift;
        return sprintf("[ %-65.65g\n  %-65.65g]",
                $min_of{id $self},
                $max_of{id $self});
}

Samotný akt přetížení operátoru provedeme takto:

use overload
        q/""/ => \&stringify;

Název operátoru pro konverzi na řetězec je "". Přetížení je možno udělat i v bloku eval, tedy dynamicky přidávat a rušit (pomocí no overload …) počas běhu programu. Všimněme si, že při použití operátoru bude zavolána naše funkce stringify pomocí normální objektové konvence, jako bychom napsali $objekt->stringify. Podobně se přetěžování chová i vůči ostatním operátorům. Jelikož se jedná o objekty, můžeme (a musíme) počítat i s dědičností u přetížených operátorů.

Nyní máme vše potřebné pro první zážehový test. V rámci propagace Perlu verze 5.10 můžeme pro konverzi na řetězec použít funkci say. Kdo tuto funkci nemá, bude muset použít „obyčejný“ print a znak \n si dodat extra sám.

use strict;
use warnings;
use IntervalFloat;
use feature qw/say/;
my $n = IntervalFloat->new(1.9);
say $n;

Výstupem tohoto programu budou právě ona dvě čísla (viz výše). Podobně můžeme zadefinovat konverzi na číslo, které vyjádříme jako něco kolem středu intervalu.

sub halferror {
        my $self = shift;
        return ($max_of{id $self} - $min_of{id $self}) / 2.0;
}
sub numerify {
        my $self = shift;
        return ($min_of{id $self} + $self->halferror);
}
use overload
        q/0+/ => \&numerify;

Podobně by šlo přetížit konverzi na booleovskou hodnotu (operátor bool). To ale dělat nemusíme, jelikož Perl je schopen si vše domyslet za použití již existující konverze na číslo.

my $f = IntervalFloat->new(0.0);
say "$f je ", $f ? "pravda" : "lez";
[ 0
  0                                                                ] je lez

Nebudeme-li lpět na absolutní přesnosti vypisování mezí, můžeme operaci konverze na řetězec napsat kompaktněji:

sub stringify {
        my $self = shift;
        return $self->numerify . ' (+/-' . $self->halferror .  ')';
}

Výstup pro číslo 1,9 pak bude:

1.9 (+/-1.08420217248550443e-19)

Přetěžování aritmetických operací

Podobným způsobem budeme pokračovat dále. Nejjednodušší binární operace je plus. V ideálním světě by stačilo napsat:

sub add {
        my ($left, $right) = @_;
        return IntervalFloat->new(
                $min_of{id $left} + $min_of{id $right},
                $max_of{id $left} + $max_of{id $right});
}
use overload
        q/+/ => \&add;

Tento kód sice fungovat bude, ale pouze za určitých okolností.

my $n = IntervalFloat->new(1.7);
my $m = IntervalFloat->new(0.2);

say $n + $m;
1.9 (+/-2.71050543121376109e-19)

Ale:

say $n + 1;
Use of uninitialized value in hash element at IntervalFloat.pm line 70.

Volání přetížené funkce funguje totiž i v případě, že jeden z parametrů není objektem třídy s přetíženými operátory. Je naší povinností pak zajistit správnou konverzi. Pokud tato situace nastane, je netřídní parametr předán vždy jako druhý, čili v proměnné $right.

sub convert {
        my ($left, $right, @rest) = @_;
        $right = IntervalFloat->new($right)
                unless ref $right and $right->isa('IntervalFloat');
        return ($left, $right, @rest);
}
sub add {
        my ($left, $right) = convert(@_);
    …
}

Nyní bude fungovat přičtení jedničky, a to dokonce i pomocí operátoru ++. Tento operátor si je opět Perl schopen dodefinovat samostatně, neřekneme-li jinak. Dodefinuje se také operátor +=.

say $n+10;
say ++$n;
11.7 (+/-1.73472347597680709e-18)
2.7 (+/-4.33680868994201774e-19)

U operátoru odečítání je potřeba vzít v úvahu, že zde záleží na pořadí parametrů a i přesto je při vykonání výrazu 1-$n poslán parametr 1 jako druhý. Pro tyto situace je binárním operátorům předán třetí parametr, který jsme dosud ignorovali. Je-li tento nastaven na pravdivou hodnotu, byly parametry předány v opačném pořadí (z výše uvedeného důvodu). Naši funkci convert tedy rozšíříme a naimplementujeme odečítání.

sub convert {
        my ($left, $right, $reversed, @rest) = @_;
        $right = IntervalFloat->new($right)
                unless ref $right and $right->isa('IntervalFloat');
        ($left, $right) = ($right, $left) if $reversed;
        return ($left, $right, $reversed, @rest);
}
sub subtract {
        my ($left, $right) = convert(@_);
        return IntervalFloat->new(
                $min_of{id $left} - $max_of{id $right},
                $max_of{id $left} - $min_of{id $right});
}
use overload
        q/-/ => \&subtract;

I v tomto případě dojde k doplnění operátorů --, -= a dokonce i unárního minus. Podobně je možné naimplementovat další matematické operace, včetně funkcí jako sincos.

Přetěžování dalších běžných operátorů

Pokud by to mělo smysl, je možné dále přetížit téměř jakýkoliv běžně používaný operátor: bitové posuny, bitové logické operátory jako &|, jejich booleovské varianty, jakož i řetězcové operace .x. U operátorů pro porovnání stačí ve většině případů přetížit operátor <=>, respektive cmp, a zbytek porovnávacích operací se dodefinuje automaticky. U našich objektů bohužel nemá smysl ani porovnání, jelikož dva intervaly lze jednoznačně porovnat, pouze pokud nemají společný průnik.

Ve verzi 5.10 lze také přetížit chytrý operátor ~~. Přetížit však nelze operátory =~!~.

Speciální parametry overload

Pokud u objektu s přetíženými operátory použijeme operátor, který není definovaný, máme možnost zavolat „catchall“ metodu (podobně jako autoloader u tříd), a to pomocí use overload nomethod => \&nasefunkce. Tato funkce dostane pak jako čtvrtý parametr řetězcovou reprezentaci volaného operátoru (např. "+").

Pomocí parametru fallback můžeme ještě získat jemnější kontrolu nad tímto mechanismem. Hodnota undef znamená, že se pro chybějící operátor nejprve použije automaticky generovaná varianta (například += pomocí existujícího +), posléze se vyzkouší nomethod a pokud ani to nepomůže, vyhodí se výjimka pomocí die. Pravdivá hodnota vypíná generování výjimek (Perl použije implicitní operátor, jako bychom neměli overload) a hodnota false naopak vypíná automatické generování operátorů.

ict ve školství 24

Závěr

V dnešním díle jsme nakoukli do světa přetěžování operátorů, který se hodí zejména pro matematické objekty – moduly rodiny Math jej hojně využívají. Možnosti přetěžování jsou ale poněkud širší, příště se tedy podíváme na ty méně obvyklé.

Pro další experimenty je možné si stáhnout modul IntervalFloat (ZIP 935 bytů).

Seriál: Perličky

Autor článku