class MyClass:
    
    def __init__(self, val):
        self.set_val(val)
        
    def get_val(self):
        return self._val
        
    def set_val(self, val):
        if val > 0:
            self._val = val
        else:
            raise ValueError('val must be greater 0')
        
myclass = MyClass(27)        
myclass._val

"""
Explanation: Objektorientiere Programmierung: Vertiefung
Diese Notebook vertieft einige Konzepte der objektorientierten Programmierung, insbesondere in Hinblick auf Python.
Geschützte Variablen und Methoden (Kapselung)
Geschützte Variablen und Methoden
Wir haben gelernt, dass einer der wesentlichen Vorteile von Objektorientierung die Datenkapselung ist. Damit ist gemeint, dass der Zugriff auf Eigenschaften und Methoden eingeschränkt werden kann. Manche Programmiersprachen wie z.B. Java markieren diese Zugriffsrechte explizit und sind in der Auslegung sehr strikt. Diese Variablendeklaration in Java beschränkt den Zugriff auf eine Variable auf die Klasse selbst:
~~~
private int score = 0;
~~~
Dadurch kann der Wert von score nur aus der Klasse heraus gelesen oder verändert werden.
~~~
public String username;
~~~
Hingegegen erlaubt den uneingeschränkten Zugriff auf die Eigenschaft username.
Diesen Mechanismus gibt es auch in Python, allerdings geht man hier die Dinge relaxter an: Ein vor einen Variablennamen oder einen Methodennamen gesetztes Underline bedeutet, dass dieser Teil des Objekt von außerhalb des Objekts nicht verwendet, vor allem nicht verändert werden soll.
End of explanation
"""


class MyClass:
    
    def __init__(self, val):
        self.__val = val
        
myclass = MyClass(42)        
myclass.__val

"""
Explanation: Wie wir sehen, ist die Eigenschaft _val durchaus von außerhalb verfügbar. Allerdings signalisiert das Underline, dass vom Programmierer der Klasse nicht vorgesehen ist, dass dieser Wert direkt verwendet wird (sondern z.B. nur über die Methoden get_val() und set_val()). Wenn ein anderer Programmierer der Meinung ist, dass er direkten Zugriff auf die Eigenschaft _val braucht, liegt das in seiner Verantwortung (wird aber von Python nicht unterbunden). Man spricht hier von protection by convention. Python-Programmierer halten sich in aller Regel an diese Konvention, weshalb dieser Art von "Schutz" weit verbreitet ist.
Unsichtbare Eigenschaften und Methoden
Für paranoide Programmierer bietet Python die Möglichkeit, den Zugriff von außerhalb des Objekt komplett zu unterbinden, indem man statt eines Unterstrichts zwei Unterstriche vor den Namen setzt.
End of explanation
"""


class MySpecialClass(MyClass):
    
    def get_val(self):
        return self.__val
    
msc = MySpecialClass(42)    
msc.get_val()

"""
Explanation: Hier sehen wir, dass die Eigenschaft __val von außerhalb der Klasse gar nicht sichtbar und damit auch nicht veränderbar ist. Innerhalb der Klasse ist sie jedoch normal verfügbar. Das kann zu Problemen führen:
End of explanation
"""


class GradingError(Exception): pass


class Student:
    
    def __init__(self, matrikelnr):
        self.matrikelnr = matrikelnr
        self._grade = 0
        
    def set_grade(self, grade):
        if grade > 0 and grade < 6:
            self._grade = grade
        else:
            raise ValueError('Grade must be between 1 and 5!')
            
    def get_grade(self):
        if self._grade > 0:
            return self._grade
        raise GradingError('Noch nicht benotet!')

"""
Explanation: Da  __val nur innerhalb der Basisklasse angelegt wurde, hat die abgeleitete Klasse keinen Zugriff darauf.
Datenkapelung mit Properties
Wie wir gesehen haben, werden für den Zugriff auf geschützte Eigenschaften eigene Getter- und Setter-Methoden geschrieben, über die der Wert einer Eigenschaft kontrolliert verändert werden kann. Programmieren wir eine Student-Klasse, in der eine Note gespeichert werden soll. Um den Zugriff auf diese Eigenschaft zu kontrollieren, schreiben wir eine Setter- und eine Getter-Methode.
End of explanation
"""


anna = Student('01754645')
anna.set_grade(6)

anna.set_grade(2)
anna.get_grade()

"""
Explanation: Wir können jetzt die Note setzen und auslesen:
End of explanation
"""


anna._grade

anna._grade = 6

"""
Explanation: Allerdings ist der direkte Zugriff auf grade immer noch möglich:
End of explanation
"""


class Student:
    
    def __init__(self, matrikelnr):
        self.matrikelnr = matrikelnr
        self.__grade = 0
        
    def set_grade(self, grade):
        if grade > 0 and grade < 6:
            self.__grade = grade
        else:
            raise ValueError('Grade must be between 1 and 5!')
            
    def get_grade(self):
        if self.__grade > 0:
            return self.__grade
        raise GradingError('Noch nicht benotet!')
        
    grade = property(get_grade, set_grade)
    
otto = Student('01745646465')    
otto.grade = 6

"""
Explanation: Wie wir bereits gesehen haben, können wir das verhindern, indem wir die Eigenschaft grade auf __grade umbenennen. 
Properties setzen via Getter und Setter
Python bietet eine Möglichkeit, das Setzen und Auslesen von Objekteigenschaften automatisch durch Methoden zu leiten. Dazu werden der Getter und Setter an die poperty-Funktion übergeben (letzte Zeile der Klasse).
End of explanation
"""


class Student:
    
    def __init__(self, matrikelnr, grade):
        self.matrikelnr = matrikelnr
        self.__grade = grade
                
    def get_grade(self):
        if self.__grade > 0:
            return self.__grade
        raise GradingError('Noch nicht benotet!')
        
    grade = property(get_grade)
    
albert = Student('0157897846546', 5)    
albert.grade

"""
Explanation: Wie wir sehen, können wir die Eigenschaft des Objekts direkt setzen und auslesen, der Zugriff wird aber von Python jeweils durch den Setter und Getter geleitet.
Wenn wir nur eine Methode (den Getter) als Argument an die property()-Funktion übergeben, haben wir eine Eigenschaft, die sich nur auslesen, aber nicht verändern lässt.
End of explanation
"""


albert.grade = 1

"""
Explanation: Wir können also auf unsere via property() definierte Eigenschaften zugreifen. Wir können grade aber nicht verwenden,
um die Eigenschaft zu verändern:
End of explanation
"""


class Student:
    
    def __init__(self, matrikelnr):
        self.matrikelnr = matrikelnr
        self.__grade = 0
            
    @property
    def grade(self):
        if self.__grade > 0:
            return self.__grade
        raise GradingError('Noch nicht benotet!')
        
    @grade.setter
    def grade(self, grade):
        if grade > 0 and grade < 6:
            self.__grade = grade
        else:
            raise ValueError('Grade must be between 1 and 5!')
    

hugo = Student('0176464645454')    

hugo.grade = 6

hugo.grade = 2

hugo.grade

"""
Explanation: Der @Property-Dekorator
Dekoratoren erweitern  dynamisch die Funktionalität von Funktionen indem sie diese (im Hintergrund) in eine weitere Funktion verpacken. Die Anwendung eines Dekorators ist einfach: man schreibt ihn einfach vor die Funktionsdefinition.
Python bringt eine Reihe von Dekoratoren mit, man kann sich aber auch eigene Dekoratoren schreiben, was jedoch hier nicht behandelt wird.
Der in Python eingebaute @property-Dekorator ist eine Alternative zu der oben vorgestellten property()-Funktion:
End of explanation
"""


class MyClass:
    
    the_answer = 42
    
    def __init__(self, val):
        self.the_answer = val
        
MyClass.the_answer        

mc = MyClass(17)
print('Objekteigenschaft:', mc.the_answer)
print('Klasseneigenschaft:', MyClass.the_answer)

"""
Explanation: Klassenvariablen (Static members)
Wir haben gelernt, dass Klassen Eigenschaften und Methoden von Objekten festlegen. Allerdings (und das kann zu Beginn etwas verwirrend sein), sind Klassen selbst auch Objekte, die Eigenschaften und Methoden haben. Hier ein Beispiel:
End of explanation
"""


class MyClass:
    instance_counter = 0
    
    def __init__(self):
        MyClass.instance_counter += 1
        print('Ich bin das {}. Objekt'.format(MyClass.instance_counter))
        
a = MyClass()
b = MyClass()

class MyOtherClass(MyClass):
    instance_counter = 0

a = MyOtherClass()
b = MyOtherClass()

"""
Explanation: Die eine Eigenschaft hängt also am Klassenobjekt, die andere am aus der Klasse erzeugten Objekt. Solche Klassenobjekte können nützlich sein, weil sie in allen aus der Klasse erzeugten Objekten verfügbar sind (sogar via self, solange das Objekt nicht selbst eine gleichnamige Eigenschaft hat:
End of explanation
"""


class MyClass:
    instance_counter = 0
    
    def __init__(self):
        self.__class__.instance_counter += 1
        print('Ich bin das {}. Objekt'.format(self.__class__.instance_counter))
        
a = MyClass()
b = MyClass()

class MyOtherClass(MyClass):
    instance_counter = 0

a = MyOtherClass()
b = MyOtherClass()

"""
Explanation: Man kann das auch so schreiben, wodurch der Counter auch für Subklassen funktioniert:
End of explanation
"""


