-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpython-17-objects.qmd
More file actions
247 lines (191 loc) · 7.48 KB
/
Copy pathpython-17-objects.qmd
File metadata and controls
247 lines (191 loc) · 7.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
---
title: "Basics of object-oriented programming"
---
<!-- Most notably, climate scientists have a very good handle on structured/procedural programming concepts, but generally -->
<!-- have little to no knowledge of the object-oriented programming concepts that underpin Python. For example, classes, -->
<!-- class methods, inheritance, etc. will likely be foreign to most people at CCCma. -->
Object-oriented programming is a way of programming in which you bundle related variables and functions into
objects, and then manipulate and use these objects as a whole. We already saw many examples of objects on
Python -- e.g. lists, dictionaries, numpy arrays, dataframes -- that have both variables and methods inside
them. In this section we will learn how to create our own objects.
You can think of:
- **variables** as *properties* / *attributes* of an object
- **functions** as some *operations* / *methods* you can perform on this object
Let's define out first class:
```py
class Planet:
# During initialization arguments will be in km and kg.
# Internally we'll store all numbers in CGS units (cm and g).
hostObject = "Sun" # class attribute (the same value for every class instance)
def __init__(self, radius, mass): # "constructor" sets the initial state of a newly created object
self.radius = radius*1e5 # instance attribute, convert km -> cm
self.mass = mass*1.e3 # instance attribute, convert kg->g
```
Let's define some instances of this class:
```py
mercury = Planet(radius=2439.7, mass=3.285e23) # enter km and kg
venus = Planet(6051.8, 4.867e24) # enter km and kg
venus.radius, venus.mass, venus.hostObject
```
Instances are guaranteed to have the attributes that we expect.
::: {.callout-caution collapse="true"}
## Exercise 17.1
How can we define an instance without passing the values? E.g., I would like to say `earth = Planet()` and
then pass the attribute values separately like this:
```py
earth = Planet()
earth.radius = 6371e5 # these are dynamic variables that we can redefine
earth.mass = 5.972e27
```
**Hint**: make it so that `Planet().radius` returns "nan" -- this can be set with `float('nan')`.
:::
<!-- ```py -->
<!-- class Planet: -->
<!-- # During initialization arguments will be in km and kg. -->
<!-- # Internally we'll store all numbers in CGS units (cm and g). -->
<!-- hostObject = "Sun" # class attribute (the same value for every class instance) -->
<!-- def __init__(self, radius=float('nan'), mass=float('nan')): -->
<!-- self.radius = radius*1e5 # instance attribute, convert km -> cm -->
<!-- self.mass = mass*1.e3 # instance attribute, convert kg->g -->
<!-- ``` -->
Let's add *inside our class* an instance method (with proper indentation):
```py
def density(self): # it acts on the class instance
return self.mass/(4./3.*math.pi*self.radius**3) # [g/cm^3]
```
Redefine the class, and now:
```py
earth = Planet()
earth.radius = 6371*1e5 # here we need to convert manually
earth.mass = 5.972e24*1e3
import math
earth.density() # 5.51 g/cm^3
```
Let's add another method (remember the indentation!):
```py
def g(self): # free fall acceleration
return 6.67259e-8*self.mass/self.radius**2 # G in [cm^3/g/s^2]
```
and now
```py
earth = Planet(6371,5.972e24)
mars = Planet(3389.5,6.39e23)
earth.g() # 981.7 cm/s^2
mars.g() / earth.g() # 0.378
```
Let's add another method (remember the indentation!):
```py
def describe(self):
print('density =', self.density(), 'g/cm^3')
print('free fall =', self.g(), 'cm/s^2')
```
Redefine the class, and now:
```py
jupyter = Planet(radius=69911, mass=1.898e27)
jupyter.describe() # should print 1.32 g/cm^3 and 2591 cm/s^2
print(jupyter) # says it is an object at this memory location (not very descriptive)
```
Let's add our last method (remember the indentation!):
```py
def __str__(self): # special method to redefine the output of print(self)
return f"My radius is {self.radius/1e5}km and my mass is {self.mass/1e3}kg"
```
Redefine the class, and now:
```py
jupyter = Planet(radius=69911, mass=1.898e27)
print(jupyter) # prints the full sentence
```
**Important**: As with any complex object in Python, assigning an instance to a new variable will simply create a
pointer, i.e. if you modify one in place, you'll see the change through the other one too:
```py
new = jupyter
jupyter.mass = -1
new.mass # also -1
```
If you want a separate copy:
```py
import copy
new = copy.deepcopy(jupyter)
jupyter.mass = -2
new.mass # still -1
```
## Inherit from parent classes
Let's create a child class `Moon` that would inherit the attributes and methods of `Planet` class:
```py
class Moon(Planet): # it inherits all the attributes and methods of the parent process
pass
phobos = Moon(radius=22.2, mass=1.08e16)
deimos = Moon(radius=12.6, mass=2.0e15)
phobos.g() / earth.g() # 0.0001489
isinstance(phobos, Moon) # True
isinstance(phobos, Planet) # True - all objects of a child class are instances of the parent class
isinstance(jupyter, Planet) # True
isinstance(jupyter, Moon) # False
issubclass(Moon,Planet) # True
```
Child classes can have their own attributes and methods that are distinct from (i.e. override) the parent class:
```py
class Moon(Planet):
hostObject = 'Mars'
def g(self):
return 'too small to compute accurately'
phobos = Moon(radius=22.2, mass=1.08e16)
deimos = Moon(radius=12.6, mass=2.0e15)
mars = Planet(3389.5,6.39e23)
phobos.hostObject, mars.hostObject # ('Mars', 'Sun')
phobos.g(), mars.g() # ('too small to compute accurately', 371.1282569773226)
```
One thing to keep in mind about class inheritance is that changes to the parent class automatically propagate to child
classes (when you follow the sequence of definitions), unless overridden in the child class:
```py
class Parent:
...
def __str__(self):
return "Changed in the parent class"
...
class Moon(Planet):
hostObject = 'Mars'
def g(self):
return 'too small to compute accurately'
deimos = Moon(radius=12.6, mass=2.0e15)
print(deimos) # prints "Changed in the parent class"
```
You can access the parent class namespace from inside a *method* of a child class by using super():
```py
class Moon(Planet):
hostObject = 'Mars'
def parentHost(self):
return super().hostObject # will return hostObject of the parent class
deimos = Moon(radius=12.6, mass=2.0e15)
deimos.hostObject, deimos.parentHost() # ('Mars', 'Sun')
```
## Generators
We already saw that in Python you can loop over a collection using `for`:
```py
for i in 'weather':
print(i)
for j in [5,6,7]:
print(j)
```
Behind the scenes Python creates an iterator out of a collection. This iterator has a `__next__()` method, i.e. it does
something like:
```py
a = iter('weather')
a.__next__() # 'w'
a.__next__() # 'e'
a.__next__() # 'a'
```
You can build your own iterator as if you were defining a function. Such function is called a *generator* in Python:
```py
def cycle():
yield 1
yield 'hello'
yield [1,2,3]
[i for i in cycle()] # [1, 'hello', [1, 2, 3]]
def square(x): # `x` is an input string in this generator
for letter in x:
yield int(letter)**2 # yields a sequence of numbers that you can cycle through
[i for i in square('12345')] # [1, 4, 9, 16, 25]
a = square('12345')
[a.__next__() for i in range(3)] # [1, 4, 9]
```