Basis

Deze pagina bevat uitvoerbare code.

De eeuwigdurende kalender, met operatoren

In week 5 bouwde je de klasse Date. Een datum vergelijken of verschuiven gaat daar met methoden: d.equals(d2), d.is_before(d2), d.add_n_days(3). Bij getallen schrijf je daarvoor ==, < en +=, en dat leest makkelijker. In deze opgave geef je Date die operatoren. Je schrijft daarvoor magische methoden: methoden die Python zelf aanroept als je een operator gebruikt. Dat heet operator overloading.

Daarna maak je af wat in week 5 openbleef. Iedereen kan nog steeds Date(30, 2, 2021) maken, een datum die niet bestaat. Die weigert de constructor voortaan met een exception. En tot slot lees je datums in die mensen zelf hebben getypt, fouten en al, zonder dat je programma bij de eerste fout stopt.

De klasse Date

Je begint met de klasse Date zoals je haar in week 5 afmaakte, na stap 13. Voer de cel hieronder uit. In deze cel voeg je in elke stap iets toe. Voer haar daarna steeds opnieuw uit, en dan de testcel van die stap.

Heb je week 5 niet af, dan is dit ook de uitwerking van die week.

class Date:
    """Een datum: dag, maand en jaar, die van buiten alleen te lezen zijn."""

    def __init__(self, day, month, year):
        """Maak een datum met de gegeven dag, maand en jaar."""
        self._day = day
        self._month = month
        self._year = year

    def __repr__(self):
        """Geeft de datum als string, zoals 02/12/2020."""
        return f"{self.day:02d}/{self.month:02d}/{self.year:04d}"

    def is_leap_year(self):
        """Geeft True als de datum in een schrikkeljaar valt."""
        if self.year % 400 == 0:
            return True
        elif self.year % 100 == 0:
            return False
        elif self.year % 4 == 0:
            return True
        return False

    def copy(self):
        """Geeft een nieuw object met dezelfde dag, maand en jaar."""
        return Date(self.day, self.month, self.year)

    def equals(self, d2):
        """Geeft True als self en d2 dezelfde kalenderdatum voorstellen."""
        return self.year == d2.year and self.month == d2.month and self.day == d2.day

    def is_before(self, d2):
        """Geeft True als self eerder valt dan d2."""
        if self.year != d2.year:
            return self.year < d2.year
        if self.month != d2.month:
            return self.month < d2.month
        return self.day < d2.day

    def is_after(self, d2):
        """Geeft True als self later valt dan d2."""
        return not self.is_before(d2) and not self.equals(d2)

    def tomorrow(self):
        """Verandert de datum in de dag erna."""
        if self.is_leap_year():
            fdays = 29
        else:
            fdays = 28
        dim = [0, 31, fdays, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

        self._day += 1
        if self.day > dim[self.month]:
            self._day = 1
            self._month += 1
            if self.month > 12:
                self._month = 1
                self._year += 1

    def yesterday(self):
        """Verandert de datum in de dag ervoor."""
        if self.is_leap_year():
            fdays = 29
        else:
            fdays = 28
        dim = [0, 31, fdays, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

        self._day -= 1
        if self.day < 1:
            self._month -= 1
            if self.month < 1:
                self._month = 12
                self._year -= 1
            self._day = dim[self.month]

    def add_n_days(self, n):
        """Verandert de datum in n dagen later en drukt elke datum onderweg af."""
        print(self)
        for i in range(n):
            self.tomorrow()
            print(self)

    def sub_n_days(self, n):
        """Verandert de datum in n dagen eerder en drukt elke datum onderweg af."""
        print(self)
        for i in range(n):
            self.yesterday()
            print(self)

    def diff(self, d2):
        """Geeft het aantal dagen van d2 tot self; negatief als self eerder valt."""
        self_copy = self.copy()
        d2_copy = d2.copy()
        count = 0
        while self_copy.is_before(d2_copy):
            self_copy.tomorrow()
            count -= 1
        while self_copy.is_after(d2_copy):
            self_copy.yesterday()
            count += 1
        return count

    def dow(self):
        """Geeft de dag van de week als Engelse string, zoals "Monday"."""
        days = [
            "Sunday",
            "Monday",
            "Tuesday",
            "Wednesday",
            "Thursday",
            "Friday",
            "Saturday",
        ]
        return days[self.diff(Date(10, 10, 2010)) % 7]

    @property
    def day(self):
        """De dag, alleen om te lezen."""
        return self._day

    @property
    def month(self):
        """De maand, alleen om te lezen."""
        return self._month

    @property
    def year(self):
        """Het jaar, alleen om te lezen."""
        return self._year

Wat je gaat maken

Stap

Methode

Maakt mogelijk

1

__eq__

d == d2: twee datums op waarde vergelijken

2

__lt__ en __gt__

d < d2 en d > d2

3

__iadd__

d += n: n dagen vooruit

4

-

begrijpen waarom __iadd__ self teruggeeft

5

__isub__

d -= n: n dagen terug

6

__sub__

d - d2: het aantal dagen ertussen

7

__init__

een datum die niet bestaat weigeren

8

parse_dates

datums uit tekst inlezen, en de ongeldige afvangen

Elke operator roept Python aan via een vaste methodenaam:

Je schrijft

Python roept aan

d == d2

d.__eq__(d2)

d < d2

d.__lt__(d2)

d > d2

d.__gt__(d2)

d += n

d = d.__iadd__(n)

d -= n

d = d.__isub__(n)

d - d2

d.__sub__(d2)

De methoden die je al hebt, zoals equals en is_before, blijven staan. De nieuwe methoden gebruiken ze.

Stap 1: __eq__(self, other)

Geeft True als other een Date is met dezelfde kalenderdatum als self. Is other geen Date, dan geeft __eq__ False.

Hiermee verandert wat == bij een datum betekent. In week 5 vergeleek == bij je eigen objecten de identiteit, net als is. Nu vergelijkt == de waarde:

Aanroep

In week 5

Na deze stap

d == d.copy()

False: twee verschillende objecten

True: dezelfde datum

d is d.copy()

False

False: nog steeds twee objecten

d == "01/01/2100"

False

False: een string is geen Date

is blijft dus de identiteit vergelijken. != hoef je niet te schrijven: Python keert het antwoord van __eq__ om.

Hint

Begin met if not isinstance(other, Date): en geef in dat geval False terug. Voor de rest heb je equals al.

d = Date(1, 1, 2100)
d2 = d.copy()
assert d == d2
assert d is not d2
assert not d == Date(2, 1, 2100)
assert d != Date(2, 1, 2100)
assert not d == "01/01/2100"

Stap 2: __lt__(self, other) en __gt__(self, other)

d < d2 geeft True als d eerder valt dan d2, en d > d2 als d later valt. Is other geen Date, dan gooien ze een TypeError:

raise TypeError("een Date is alleen met een Date te vergelijken")

Aanroep

Resultaat

Date(2, 12, 2020) < Date(1, 1, 2021)

True

Date(1, 1, 2021) > Date(2, 12, 2020)

True

Date(2, 12, 2020) < Date(2, 12, 2020)

False

Waarom False bij == en een foutmelding bij <? Een datum en een string zijn gewoon niet gelijk. Maar of een datum eerder valt dan een string, is een vraag zonder antwoord.

Python kan d > d2 ook zonder __gt__ beantwoorden, door de vergelijking om te draaien naar d2 < d. Je schrijft __gt__ hier toch, zodat > naast is_after staat, zoals < naast is_before.

Hint

Schrijf de vergelijking niet opnieuw: is_before en is_after heb je al.

ny = Date(1, 1, 2021)
d = Date(2, 12, 2020)
assert d < ny
assert not ny < d
assert not d < d
assert ny > d
assert not d > ny
assert not d > d

Stap 3: __iadd__(self, n)

Verandert self in een datum n dagen later, en geeft self terug. n is een integer, nooit negatief. Is n geen integer, dan gooit __iadd__ een TypeError. isinstance werkt ook met een ingebouwd type: isinstance(n, int) geeft True als n een integer is.

Voor

Code

Na

28/02/2021

d += 3

03/03/2021

03/03/2021

d += 1318

11/10/2024

add_n_days drukt elke datum onderweg af. __iadd__ doet dat niet: bij d += 1318 wil je geen 1319 regels zien. Roep dus niet add_n_days aan, maar tomorrow, in een for-lus. Zet return self aan het eind; waarom, zie je in stap 4.

+= verandert het object zelf, net als bij een lijst. Wijst een tweede naam hetzelfde object aan, dan zie je de verandering ook via die naam. De assertion d2 is d hieronder laat dat zien.

De laatste regels van de test gebruiken try en except uit de opstap. Ze controleren dat d += 1.5 een TypeError gooit: alleen dan wordt got_error True. Gebruik je range(n) in de lus, dan gooit range bij 1.5 zelf al een TypeError, ook zonder je controle met isinstance. De test ziet dus niet of die controle er staat; kijk dat zelf na.

d = Date(28, 2, 2021)
d += 3
assert repr(d) == "03/03/2021"
d += 0
assert repr(d) == "03/03/2021"
d2 = d
d += 1318
assert repr(d) == "11/10/2024"
assert d2 is d
got_error = False
try:
    d += 1.5
except TypeError:
    got_error = True
assert got_error

Stap 4: waarom return self

Haal in __iadd__ de regel return self even weg, voer de cel met de klasse opnieuw uit, en voer daarna deze code uit:

d = Date(28, 2, 2021)
d += 3
print(d)

Beantwoord in de cel hieronder, als commentaar:

  1. Wat drukt de code af?

  2. In de tabel bij Wat je gaat maken staat dat Python d += n uitvoert als d = d.__iadd__(n). Wat geeft __iadd__ zonder return self terug, en wat wijst d daarna dus aan?

  3. Is de datum zelf wel drie dagen verschoven?

Zet return self daarna terug, en voer de cel met de klasse opnieuw uit.

# jouw antwoorden

Stap 5: __isub__(self, n)

Het spiegelbeeld van __iadd__: verandert self in een datum n dagen eerder, en geeft self terug. Is n geen integer, dan gooit __isub__ een TypeError. Voor de laatste regels van de test geldt wat bij stap 3 staat.

Voor

Code

Na

03/03/2021

d -= 3

28/02/2021

01/01/2021

d -= 366

01/01/2020

d = Date(3, 3, 2021)
d -= 3
assert repr(d) == "28/02/2021"
d = Date(1, 1, 2021)
d -= 366
assert repr(d) == "01/01/2020"
got_error = False
try:
    d -= "drie"
except TypeError:
    got_error = True
assert got_error

Stap 6: __sub__(self, other)

d - d2 geeft het aantal dagen tussen d en d2 als integer, precies zoals d.diff(d2). Net als diff verandert - de twee datums niet. Is other geen Date, dan gooit __sub__ een TypeError.

Aanroep

Resultaat

Date(19, 7, 2021) - Date(2, 12, 2020)

229

Date(2, 12, 2020) - Date(19, 7, 2021)

-229

Date(2, 12, 2020) - 5

een TypeError

Let op het verschil met stap 5. d - d2 verandert geen van beide datums: het geeft een getal. d -= 5 verandert d zelf.

d = Date(2, 12, 2020)
d2 = Date(19, 7, 2021)
assert d2 - d == 229
assert d - d2 == -229
assert d - d == 0
assert repr(d) == "02/12/2020"
assert repr(d2) == "19/07/2021"

Voer daarna deze cel uit, en lees de laatste regel van de foutmelding:

d = Date(2, 12, 2020)
print(d - 5)

Stap 7: een datum die niet bestaat, weigeren

In week 5 schermde je de datum af: code van buiten de klasse kan dag, maand en jaar niet meer veranderen. Eén deur bleef open, de constructor. Date(30, 2, 2021) maakt nog steeds een datum die niet bestaat. Die deur sluit je nu.

Laat de constructor een ValueError gooien als de maand niet van 1 tot en met 12 is, of als de dag niet in die maand bestaat:

raise ValueError(f"{self} bestaat niet")

Aanroep

Resultaat

Date(29, 2, 2024)

een datum: 2024 is een schrikkeljaar

Date(30, 2, 2021)

ValueError: 30/02/2021 bestaat niet

Date(31, 4, 2021)

ValueError: 31/04/2021 bestaat niet

Date(1, 13, 2021)

ValueError: 01/13/2021 bestaat niet

Date(0, 5, 2024)

ValueError: 00/05/2024 bestaat niet

Hint

Zet eerst de drie attributen, zoals nu al gebeurt; dan kan de constructor self.is_leap_year() gebruiken. Daarna heb je de lijst dim uit tomorrow nodig. Controleer de maand vóór je dim[month] opzoekt: bij maand 13 bestaat die index niet.

assert repr(Date(29, 2, 2024)) == "29/02/2024"
assert repr(Date(31, 12, 2021)) == "31/12/2021"
assert repr(Date(1, 1, 2000)) == "01/01/2000"
d = Date(28, 2, 2021)
d += 1
assert repr(d) == "01/03/2021"
got_error = False
try:
    Date(0, 5, 2024)
except ValueError:
    got_error = True
assert got_error

Voer daarna deze cel uit. Hij hoort een ValueError te geven:

d = Date(30, 2, 2021)

Stap 8: datums uit tekst inlezen

Op een aanmeldformulier typen mensen hun geboortedatum zelf in, en niet iedereen doet dat goed. Schrijf een functie parse_dates(texts), buiten de klasse. Ze krijgt een lijst strings in de vorm dag/maand/jaar en geeft een lijst Date-objecten terug, één voor elke geldige string. Een string waar geen datum van te maken is, slaat ze over, en ze drukt overgeslagen: af met die string erachter.

String

Wat er gebeurt

"30/01/2024"

wordt Date(30, 1, 2024)

"1/5/2024"

wordt Date(1, 5, 2024)

"31/04/2024"

overgeslagen: april heeft 30 dagen

"01-05-2024"

overgeslagen: geen schuine strepen

Zo handel je de fouten af: het programma meldt ze en gaat door, in plaats van te stoppen bij de eerste.

Hint

day, month, year = text.split("/") zet de drie delen van de string in drie namen. Zijn er geen drie delen, dan gooit Python een ValueError. Dat doet int ook bij een deel dat geen getal is, en je constructor bij een datum die niet bestaat. Eén try met except ValueError, binnen de lus, vangt dus alle drie af.

Met de lijst in de test hieronder drukt parse_dates vijf regels af:

overgeslagen: 31/04/2024
overgeslagen: 29/02/2023
overgeslagen: 15/13/2024
overgeslagen: 00/05/2024
overgeslagen: 01-05-2024
# jouw oplossing
texts = [
    "30/01/2024",
    "31/04/2024",
    "29/02/2024",
    "29/02/2023",
    "15/13/2024",
    "00/05/2024",
    "01-05-2024",
    "1/5/2024",
]
dates = parse_dates(texts)
assert [repr(d) for d in dates] == ["30/01/2024", "29/02/2024", "01/05/2024"]
assert parse_dates([]) == []

Tot slot

Je Date werkt nu met de operatoren van Python, en er bestaat geen Date meer die niet kan bestaan: de constructor weigert hem, en tomorrow en yesterday maken van een bestaande datum altijd een bestaande. Wie een datum uit tekst maakt, krijgt bij een fout een exception en beslist zelf wat ermee gebeurt.

In het practicum geef je wezens en partijen operatoren, en laat je de stille terugvallen van week 5 en 6 plaatsmaken voor exceptions. In de extra-opgave vergelijk je zetten van Vier op een rij met <.