Basis

Deze pagina bevat uitvoerbare code.

Een eeuwigdurende kalender

Een eeuwigdurende kalender vertelt je op welke weekdag een willekeurige datum valt. Wie op 13 juni 1997 is geboren, leest er af dat dat een vrijdag was: vrijdag de dertiende, zelfs.

In deze opgave breid je de klasse Date uit het college uit tot zo’n kalender. Je objecten van het type Date kunnen straks uitrekenen hoeveel dagen er tussen twee datums liggen, en op welke weekdag een datum valt. Aan het eind zorg je dat code van buiten de klasse de datum van een Date niet meer kan veranderen: alleen de methoden van de klasse doen dat nog.

De klasse Date

Je begint met de klasse uit het college. Ze heeft drie attributen: self.day, self.month en self.year, en naast de constructor en __repr__ één eigen methode, is_leap_year.

Voer de cel hieronder uit. In deze cel voeg je in elke stap een methode toe. Voer hem daarna steeds opnieuw uit, en dan de testcel van die stap.

In de testcellen staat repr(d). Dat geeft de string die __repr__ teruggeeft: dezelfde die print(d) afdrukt.

class Date:
    """Een datum: dag, maand en jaar."""

    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

Wat je gaat maken

Stap

Methode

Doet

1

-

klasse en object uit elkaar houden

2

copy

een nieuw object met dezelfde datum maken

3

equals

twee datums op waarde vergelijken

4

is_before

kijken of een datum eerder valt

5

is_after

kijken of een datum later valt

6

tomorrow

een dag vooruit

7

yesterday

een dag terug

8

add_n_days

n dagen vooruit

9

sub_n_days

n dagen terug

10

diff

het aantal dagen tussen twee datums

11

dow

de dag van de week

12

-

beoordelen wat er misgaat als iedereen bij de datum kan

13

day, month, year

de datum afschermen

Stap 1: klasse en object

Een klasse is het ontwerp: ze legt vast welke attributen en methoden elk object krijgt. Een object is één exemplaar volgens dat ontwerp, met eigen waarden voor die attributen. Bekijk deze drie regels:

ny = Date(1, 1, 2021)
nc = Date(1, 1, 2100)
d = ny

Beantwoord in de cel hieronder, als commentaar:

  1. Hoeveel klassen en hoeveel objecten komen er in deze drie regels voor?

  2. Welke waarde heeft het attribuut year bij ny, en welke bij nc?

  3. De methode is_leap_year staat één keer in de klasse. Toch geeft ny.is_leap_year() een ander antwoord dan Date(1, 1, 2024).is_leap_year(). Hoe kan dat?

Hint

Een nieuw object ontstaat alleen als je de klasse aanroept, zoals in Date(1, 1, 2021). a is b geeft True als a en b hetzelfde object aanwijzen: twee namen voor één object.

Voer daarna de testcel uit, en kijk of de uitvoer klopt met je antwoorden.

# jouw antwoorden
ny = Date(1, 1, 2021)
nc = Date(1, 1, 2100)
d = ny
print(ny is d)
print(ny is nc)
print(ny.year, nc.year)
print(ny.is_leap_year(), Date(1, 1, 2024).is_leap_year())

Stap 2: copy(self)

Geeft een nieuw object terug met dezelfde dag, maand en jaar. Deze methode krijg je cadeau; neem haar over in je klasse:

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

Om een nieuw object te maken, roep je de constructor aan: Date(...) binnen de methode doet precies hetzelfde als daarbuiten.

Het verschil met een gewone toekenning:

Code

Wat er gebeurt

d2 = d

geen nieuw object: d2 is een tweede naam voor hetzelfde object

d3 = d.copy()

een nieuw object, met dezelfde datum als d

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

Stap 3: equals(self, d2)

Geeft True als self en d2 dezelfde kalenderdatum voorstellen, ook als het twee verschillende objecten zijn. Ook deze methode krijg je cadeau:

    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

Hier zie je het verschil tussen waarde en identiteit. Bij je eigen objecten vergelijkt == of het hetzelfde object is: de identiteit, net als d is d2. equals vergelijkt de waarde: dezelfde dag, maand en jaar.

Aanroep

Resultaat

d.equals(d.copy())

True: dezelfde datum

d == d.copy()

False: twee verschillende objecten

d = Date(1, 1, 2100)
d2 = d.copy()
assert d.equals(d2)
assert d.equals(Date(1, 1, 2100))
assert not d.equals(Date(2, 1, 2100))
assert not d == d2

Stap 4: is_before(self, d2)

Geeft True als self een eerdere datum is dan d2. Bij dezelfde datum, of bij een latere, geeft de methode False.

Aanroep

Resultaat

Date(2, 12, 2020).is_before(Date(1, 1, 2021))

True

Date(1, 1, 2021).is_before(Date(2, 12, 2020))

False

Date(2, 12, 2020).is_before(Date(2, 12, 2020))

False

Hint

Vergelijk eerst de jaren. Vergelijk de maanden alleen als de jaren gelijk zijn, en de dagen alleen als de jaren en de maanden allebei gelijk zijn.

ny = Date(1, 1, 2021)
d = Date(2, 12, 2020)
assert not ny.is_before(d)
assert d.is_before(ny)
assert not d.is_before(d)
assert not Date(1, 3, 2020).is_before(Date(2, 2, 2020))
assert Date(31, 12, 2019).is_before(Date(1, 1, 2020))

Stap 5: is_after(self, d2)

Geeft True als self een latere datum is dan d2. Bij dezelfde datum, of bij een eerdere, geeft de methode False.

Aanroep

Resultaat

Date(1, 1, 2021).is_after(Date(2, 12, 2020))

True

Date(2, 12, 2020).is_after(Date(1, 1, 2021))

False

Date(2, 12, 2020).is_after(Date(2, 12, 2020))

False

Hint

Je kunt is_after op dezelfde manier schrijven als is_before. Korter is het om is_before en equals te gebruiken: een datum die niet eerder is en niet gelijk, is later.

ny = Date(1, 1, 2021)
d = Date(2, 12, 2020)
assert ny.is_after(d)
assert not d.is_after(ny)
assert not d.is_after(d)
assert Date(1, 3, 2020).is_after(Date(2, 2, 2020))

Stap 6: tomorrow(self)

Verandert het object zodat het een dag later voorstelt. De methode geeft niets terug. self.day verandert altijd; self.month en self.year soms.

Voor

Na tomorrow()

31/12/2020

01/01/2021

28/02/2020

29/02/2020

29/02/2020

01/03/2020

28/02/2021

01/03/2021

Hint

Een lijst met het aantal dagen per maand is handig:

dim = [0, 31, fdays, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

fdays is 29 in een schrikkeljaar en anders 28; dat regel je met een gewone if-else en is_leap_year. Zie je waarom die 0 aan het begin handig is? Bedenk hoe lijsten geïndexeerd worden.

d = Date(31, 12, 2020)
d.tomorrow()
assert repr(d) == "01/01/2021"
d = Date(28, 2, 2020)
d.tomorrow()
assert repr(d) == "29/02/2020"
d.tomorrow()
assert repr(d) == "01/03/2020"
d = Date(28, 2, 2021)
d.tomorrow()
assert repr(d) == "01/03/2021"

Stap 7: yesterday(self)

Verandert het object zodat het een dag eerder voorstelt, en geeft net als tomorrow niets terug.

Voor

Na yesterday()

01/01/2021

31/12/2020

01/03/2020

29/02/2020

29/02/2020

28/02/2020

01/03/2021

28/02/2021

d = Date(1, 1, 2021)
d.yesterday()
assert repr(d) == "31/12/2020"
d = Date(1, 3, 2020)
d.yesterday()
assert repr(d) == "29/02/2020"
d.yesterday()
assert repr(d) == "28/02/2020"
d = Date(1, 3, 2021)
d.yesterday()
assert repr(d) == "28/02/2021"

Stap 8: add_n_days(self, n)

Verandert het object zodat het n dagen later voorstelt, en drukt onderweg elke datum af, de startdatum eerst. n is nooit negatief. De methode geeft niets terug.

d = Date(2, 12, 2020)
d.add_n_days(4)

drukt af:

02/12/2020
03/12/2020
04/12/2020
05/12/2020
06/12/2020

Met d.add_n_days(1318) kom je van 02/12/2020 uit op 12/07/2024.

Let op

Kopieer hiervoor geen code uit tomorrow. Bedenk in plaats daarvan hoe je tomorrow kunt aanroepen in een for-lus. Een object drukt zichzelf af met print(self).

d = Date(2, 12, 2020)
d.add_n_days(4)
assert repr(d) == "06/12/2020"
d.add_n_days(0)
assert repr(d) == "06/12/2020"

Stap 9: sub_n_days(self, n)

Het spiegelbeeld van add_n_days: verandert het object zodat het n dagen eerder voorstelt, en drukt onderweg elke datum af. Gebruik yesterday in een for-lus.

Voor

Na sub_n_days(4)

06/12/2020

02/12/2020

d = Date(6, 12, 2020)
d.sub_n_days(4)
assert repr(d) == "02/12/2020"

Stap 10: diff(self, d2)

Geeft het aantal dagen tussen self en d2 terug, als integer. Het teken telt:

Geval

Resultaat

self en d2 zijn dezelfde datum

0

self is eerder dan d2

een negatief aantal dagen

self is later dan d2

een positief aantal dagen

Aanroep

Resultaat

Date(19, 7, 2021).diff(Date(2, 12, 2020))

229

Date(2, 12, 2020).diff(Date(19, 7, 2021))

-229

Date(15, 3, 2020).diff(Date(1, 12, 2019))

105

Belangrijk

diff mag self en d2 niet veranderen. Maak kopieën en werk alleen met die:

self_copy = self.copy()
d2_copy = d2.copy()

Hint

Trek niet de jaren, maanden en dagen van elkaar af: daar zitten te veel uitzonderingen in. Gebruik tomorrow of yesterday in een while-lus, met een conditie als while self_copy.is_before(d2_copy):. Een verzamelvariabele telt hoe vaak de lus draait; dat is je antwoord, eventueel met een minteken.

Met diff kun je je eigen leeftijd in dagen uitrekenen. Andere verschillen controleer je op https://www.timeanddate.com/date/duration.html.

d = Date(2, 12, 2020)
d2 = Date(19, 7, 2021)
assert d2.diff(d) == 229
assert d.diff(d2) == -229
assert d.diff(d) == 0
assert repr(d) == "02/12/2020"
assert repr(d2) == "19/07/2021"
assert Date(15, 3, 2020).diff(Date(1, 12, 2019)) == 105
assert d.diff(Date(1, 1, 1899)) == 44530
assert d.diff(Date(1, 1, 2100)) == -28884

Stap 11: dow(self)

Geeft de dag van de week (day of week) terug waarop de datum valt, als een van de strings "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" of "Sunday".

Aanroep

Resultaat

Date(7, 12, 1941).dow()

"Sunday"

Date(28, 10, 1929).dow()

"Monday"

Date(1, 1, 2100).dow()

"Friday"

Hint

Neem een datum waarvan je de weekdag kent, zoals zondag 10 oktober 2010, en bereken met diff hoeveel dagen je datum daarvan af ligt. Hoe helpt de modulo-operator % je dan verder?

assert Date(7, 12, 1941).dow() == "Sunday"
assert Date(28, 10, 1929).dow() == "Monday"
assert Date(19, 10, 1987).dow() == "Monday"
assert Date(1, 1, 2100).dow() == "Friday"
assert Date(13, 6, 1997).dow() == "Friday"

Stap 12: een datum die niet bestaat

Tot nu toe kan iedereen de attributen van een Date rechtstreeks veranderen. Voer deze code uit:

d = Date(28, 2, 2021)
d.day = 30
print(d, d.dow())
d.tomorrow()
print(d, d.dow())

Beantwoord in de cel hieronder, als commentaar:

  1. Bestaat de datum die als eerste wordt afgedrukt?

  2. Welke datums en weekdagen drukt de code af? Kan dat kloppen? Welke methode van Date heeft hier een fout gemaakt?

  3. Welke methoden van Date veranderen de datum van een object? Kan een van die methoden van een bestaande datum een datum maken die niet bestaat?

  4. Welke regel code heeft de datum dan wel kapotgemaakt, en waarom kon de klasse dat niet tegenhouden?

# jouw antwoorden

Stap 13: de datum afschermen

In stap 12 zag je dat de methoden van Date van een bestaande datum nooit een onbestaande maken. Code van buiten de klasse kan dat wel, zolang iedereen day, month en year kan veranderen. Dat sluit je nu af. Dat is encapsulatie: de staat van het object is verborgen, en alleen zijn methoden komen eraan.

Pas je klasse zo aan dat dag, maand en jaar van buiten wel te lezen zijn, maar niet te veranderen:

  1. Sla ze in de constructor op als self._day, self._month en self._year.

  2. Geef de klasse drie properties, day, month en year, zonder setter.

  3. Zoek in je methoden elke plek waar dag, maand of jaar een nieuwe waarde krijgt, en schrijf daar self._day, self._month of self._year.

Hint

Zoek op self.day =, self.day += en self.day -=, en hetzelfde voor month en year. Waar een methode alleen leest, zoals __repr__, mag self.day blijven staan: dat gaat nu via de property.

Voer na de testcel hieronder ook de testcellen van stap 2 tot en met 11 nog eens uit; die horen allemaal te blijven slagen. De code van stap 12 geeft nu een foutmelding: AttributeError: property 'day' of 'Date' object has no setter. Precies de bedoeling.

Eén deur staat nog open: de constructor. Date(30, 2, 2021) maakt nog steeds een datum die niet bestaat. Hoe een constructor zo’n aanroep weigert, met raise, leer je in week 7.

d = Date(28, 2, 2021)
assert d.day == 28
d.tomorrow()
assert repr(d) == "01/03/2021"
assert d.day == 1
assert d.month == 3
assert d.year == 2021
assert Date(2, 12, 2020).diff(Date(1, 12, 2019)) == 367

Tot slot

Je klasse Date is nu een eeuwigdurende kalender. Alle regels over dagen, maanden en schrikkeljaren staan op één plek, in de klasse, en elke verandering van een bestaande datum loopt via een methode die die regels kent. Alleen de constructor neemt nog elke datum aan; daar kom je in week 7 op terug. In het practicum ontwerp je zo’n klasse vanaf het begin: een wezen dat zijn eigen levenspunten bewaakt.