# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.14.0
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Modularisierung

# %% [markdown]
# Module (modules) sind Sammlungen von Pythonprogrammen mit der Enddung .py, die Datentypen und Funktionen bereitstellen. Module werden wieder zu Bibliotheken (libraries) oder Pakete (packages) zusammengefasst.

# %% [markdown]
# ## Einbinden von Modulen

# %% [markdown]
# Beispielsweise ist __numpy.random__ ein Modul, das Programme enthält, die Zufallszahlen erzeugen.

# %%
from numpy.random import randint

# %%
randint(5), randint(5), randint(5)

# %% [markdown]
# Das math-Modul stellt mathematische Funktionen und Konstanten zur Verfügung, wie z.B. die Konstante $\pi$ sowie die Funktionen $\sin()$ und $\cos()$. 
#
# Nach dem Schlüsselwort __import__ können mehrere (durch Komma getrennte) Modulnamen folgen. 
#
# Obwohl import-Anweisungen an jeder Stelle des Quellcodes stehen dürfen, ist es üblich diese Anweisungen am Anfang des Quellcodes zu plazieren.

# %%
import math # importiert das math-Modul aus der Standardbibliothek (C Standard Bibliothek)

# %%
import math
math.pi

# %%
from math import pi
pi

# %%
math.sin(math.pi/2)

# %% [markdown]
# Man kann auch mehrere Funktionen gleichzeitig importieren 

# %%
from math import sin, cos
sin(pi/4)*cos(pi/4)

# %% [markdown]
# Man kann eine Bibliothek auch komplett in den globalen Namensraum einbinden. Dabei werden dann ggf. bereits vorhandene gleichlautende Namen überschrieben. Das macht man eigentlich nur in mit sympy.

# %%
from math import *
tan(pi/4)

# %% [markdown]
# Beim Importieren einer Bibliothek kann man auch einen neuen Namen für den Namensraum wählen, damit vermeidet man, dass vorhandene gleichlautende Namen anderer Module ungewollt überschreiben werden.  

# %%
import math as m

(m.log(m.e))

# %% [markdown]
# ## Inhalte von eingebundenen Modulen anzeigen

# %% [markdown]
# Mit der built-in Funktion __dir()__ kann man sich die in einem Modul definierten Namen anzeigen lassen.

# %%
import math as m
dir(m)

# %% [markdown]
# Mit der built-in Funktion __help()__ kann man sich genauere Informationen verschaffen.

# %%
help(sqrt)

# %% [markdown]
# # Einführung in Klassen

# %% [markdown]
# Eine ausführlichere Beschreibung finden Sie hier: https://www.python-kurs.eu/klassen.php
#
# Python ist eine klassenbasierte Programmiersprache. Alle Datentypen und Funktionen sind Objekte/Instanzen von Python Klassen, wie man durch Verwendung der __type()__ Funktion sehen kann. (Die Begriffe  Objekt und Instanz werden synonym verwendet.)
#
# Selbst __None__ ist ein Objekt der Klasse NoneType.

# %%
print(type(None))

# %% [markdown]
# Eine Klasse definiert einen Bauplan nach dem die Objekte der Klasse erzeugt werden. Eine Instanz vom Typ Liste erhält man durch:

# %%
liste = [3, 4, 5, 6] 

# %%
print(type(liste))

# %% [markdown]
# liste ist eine Instanz der Klasse list, genauer eine Referenz auf eine Instanz der Klasse list. Allen Instanzen der Klasse list stehen Attribute und Methoden zur Verfügung, die durch die Klasse definiert werden. Man kann sich diese Methoden anzeigen lassen. Dazu schreibt man
# ```pyton
# liste.
# ```
# plaziert den Cursor hinter dem Punkt und drückt die Tab Taste.
#
# Es erscheint dann eine Liste möglicher Attribute und Methoden. 

# %%
liste.

# %% [markdown]
# Eine vollständige Aufstellung der Attribute und Methoden kann man sich mit __dir__ ausgeben lassen. Alles was mit einem Unterstrich beginnt ist privat, und wird meist nur intern verwendet.

# %%
dir(liste)

# %% [markdown]
# Die Methode __append__ kennen Sie schon.

# %%
liste.append(1) 
liste

# %% [markdown]
# Für Listenobjekte ist durch \_\___add__\_\_ eine Addition definiert. Diese funktioniert wie folgt.

# %%
liste.__add__([1, 3, 4, 1])

# %%
oder einfacher 

# %%
liste + [1, 3, 4, 1]


# %% [markdown]
# Für die lineare Algebra sind Listen also nicht so einfach als Vektoren zu gebrauchen.

# %% [markdown] slideshow={"slide_type": "slide"}
# # Eine erste minimale Klasse.
# Die minimale Definition einer Klasse in Python hat die Form
# ```python
#     class KlassenName:
#         Anweisungen
# ```

# %% slideshow={"slide_type": "fragment"}
class ErsteKlasse:
    pass # pass gibt an, dass hier noch irgendwas hin soll


# %% slideshow={"slide_type": "fragment"}
p = ErsteKlasse()
p


# %% slideshow={"slide_type": "slide"}
class ZweiteKlasse:
    """ eine zweite Klasse """
    n = 1234  # Attribut
    def f(x): # Methode
        return x**2

z = ZweiteKlasse
z
dir(z)

# %%
print(z.__dict__)

# %%
# ?z

# %%
z.n

# %%
z.f(2)


# %% [markdown] slideshow={"slide_type": "slide"}
# ## Klasse Student

# %% slideshow={"slide_type": "fragment"}
class Student(object):
    
    noten = []                       
    
    def __init__(self, Vn, Nn, MatNr):
        self.Vorname = Vn
        self.Nachname = Nn
        self.MatrikelNummer = MatNr
    #'__init__ wird automatisch beim Erstellen eines Objektes aufgerufen

    # Klassenmethoden
    def notehinzufuegen(self, note):
        self.noten.append(note)
        
    def hallo(self):
        print(f'Hallo mein Name ist {self.Vorname} {self.Nachname}')
 
           
a = Student('Li', 'Ping', 101)
b = Student('Lisa', 'Paul', 102)
a.notehinzufuegen(1)
b.notehinzufuegen(4)

a.hallo()
b.hallo()

a.noten # das ist vermutlich nicht gewünscht


# %% [markdown] slideshow={"slide_type": "slide"}
# # Bessere Lösung
#

# %% slideshow={"slide_type": "fragment"}
class Student(object):
     
    def __init__(self,Vn,Nn,MatNr):
        self.Vorname = Vn
        self.Nachname = Nn
        self.MatrikelNummer = MatNr
        self.noten = []
        
    def notehinzufuegen(self,note):
        self.noten.append(note)
        
    def hallo(self):
        print(f'Hallo mein Name ist {self.Vorname} {self.Nachname}')
 


# %% slideshow={"slide_type": "fragment"}
a = Student('Li', 'Ping', 101)
b = Student('Lisa', 'Paul', 102)
a.notehinzufuegen(1)
b.notehinzufuegen(4)

a.noten, b.noten


# %% [markdown] slideshow={"slide_type": "slide"}
#  # Rules regarding self
#  (from Lantangen's book, page 419):
#
#  1) Any class method must have self as first argument. (The name can be any 
#     valid variable name, but the name self is a widely established convention in
#      Python.) 
#
#  2) self represents an (arbitrary) instance of the class.
#
#  3) To access any class attribute inside class methods, we must prefix with self,
#  as in self.name, where name is the name of the attribute.
#
#  4) self is dropped as argument in calls to class methods.
#

# %% slideshow={"slide_type": "slide"}
class Student(object):
    
    def __init__(self,Vn,Nn,MatNr):
        self.Vorname = Vn
        self.Nachname = Nn
        self.MatrikelNummer = MatNr
        self.noten = []
        
    def saghallo(self):
        print(f'Hallo mein Name ist {self.Vorname} {self.Nachname}')
 
    def brief(self):
        return f'Guten Tag {self.Vorname} {self.Nachname}, Deine email ist {self.email()}'
        
    def email(self):
        return f'{self.Vorname}.{self.Nachname}@hhu.de'
            
a = Student('Li', 'Ping', 101)
b = Student('Lisa', 'Paul', 102)
a.saghallo()
a.brief()


# %% [markdown] slideshow={"slide_type": "slide"}
# ## Ein mathematisches Beispiel
# Eine Klasse für Polynome 
#
#

# %% slideshow={"slide_type": "fragment"}
class Polynom(object):
    """ Polynomklasse Beschreibung TODO.....
    """
    def __init__(self, dp):
        if isinstance(dp,(int,float,complex)):
            dp = {0:dp}
        self.dp = dp
        self.degree = max(dp.keys())
        
    def __repr__(self):
        polystr = ''
        for k in sorted(self.dp):
            polystr = polystr + f'{self.dp[k]:+g}*X^{k}' # das + sorgt dafür, dass das Vorzeichen ausgegeben wird
        return 'Polynom: ' + polystr 
    
    def __add__(self, other):
        spow = set(self.dp.keys())
        opow = set(other.dp.keys())
        pows = spow.union(opow)
        pps = dict()
        for k in pows:
            if k in spow:
                if k in opow:
                    pps[k] = self.dp[k] + other.dp[k]
                else:
                    pps[k] = self.dp[k]
            else:
                pps[k] =  other.dp[k]
        
        return Polynom(pps)
    
    def __sub__(self, other):
        """ Substraktion zweier Polynomen 
        """
        selfpow = set(self.dp.keys())
        otherpow = set(other.dp.keys())
        pows = selfpow.union(otherpow)
        erg = dict()
        for k in pows:
            if k in selfpow:
                if k in otherpow:
                    erg[k] = self.dp[k] - other.dp[k]
                else:
                    erg[k] = self.dp[k]
            else:
                erg[k] =  -other.dp[k]
        
        return Polynom(erg)
    def __call__(self, x):
        """ Auswertung des Polynoms 
        """
        return sum([self.dp[k]*x**k for k in self.dp])
    
    def __mul__(self, other):
        """ Multiplikation eines Polynoms mit einem Skalar 
        TODO Polynommultiplikation
        """
        erg = dict()
        if isinstance(other, (int, float, complex)):
            for k in self.dp:
                erg[k] = self.dp[k] *other       
            return Polynom(erg)
        
    __rmul__ = __mul__
            


# %% slideshow={"slide_type": "slide"}
pd = {0:-1, 10:2.5}
qd = {0:2/3, 5:34}
p = Polynom(pd)
q = Polynom(qd)
p, q

# %% slideshow={"slide_type": "fragment"}
print(p)  # in __repr__ definiert

# %% slideshow={"slide_type": "fragment"}
p+q       # in __add__ definiert

# %% slideshow={"slide_type": "fragment"}
p-q

# %% slideshow={"slide_type": "fragment"}
2*p

# %% slideshow={"slide_type": "fragment"}
p*2

# %% slideshow={"slide_type": "fragment"}
2*p+q

# %% slideshow={"slide_type": "fragment"}
p(2) # in __call__ definiert

# %%
c = Polynom(1)
c


# %% [markdown]
# ## Vererbung
# Um eine neue abgeleitete Klasse zu definieren, die von einer Basisklasse deren Methoden und Attribute erbt verwendete man
# ```python
# class DerivedClassName(BaseClassName):
#     Anweisungen
# ```

# %% [markdown]
# Wir ändern/überschreiben jetzt nur die Art wie Polynome von Grad kleiner gleich 1 erzeugt werden. Dann sind Objekte der Klasse Gerade einfach spezielle Polynome

# %% run_control={"marked": false}
class Gerade(Polynom):
    """ Geraden Beschreibung TODO.....
    """
    def __init__(self, m, t):
        Polynom.__init__(self,{0:t, 1:m})
        
    

# %%
t = Gerade(1, 0)
t

# %%
t2 = Gerade(2, 1)

# %%
t+t2

# %%
