-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcourses.txt
More file actions
2451 lines (1545 loc) · 114 KB
/
Copy pathcourses.txt
File metadata and controls
2451 lines (1545 loc) · 114 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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
Python Certification
Understanding Object Oriented Programming and Encapsulation
What is Object-Oriented Programming, and How Does Encapsulation Work?
Object-oriented programming, also known as OOP, is a programming style in which developers treat everything in their code like a real-world object.
A class is like a blueprint for creating objects. Every single object created from a class has attributes that define data and methods that define the behaviors of the objects.
In a previous lesson, you learned how to create classes. Here's a reminder of the syntax:
class ClassName:
def __init__(self, parameters):
attribute = value
def method_name(self):
# method logic
Here's an example of a class that uses the __init__ special method to initialize the brand and color attributes whenever an object is created using the class:
class Car:
def __init__(self, brand, color):
self.brand = brand
self.color = color
# create two objects from the Car class
car1 = Car('Toyota', 'red')
car2 = Car('Lambo', 'green')
print('Car 1 Brand:', car1.brand) # Car 1 Brand: Toyota
print('Car 1 Color:', car1.color) # Car 1 Color: red
print('Car 2 Brand:', car2.brand) # Car 2 Brand: Lambo
print('Car 2 Color:', car2.color) # Car 2 Color: green
Object-oriented programming has four key principles that help you organize and manage code effectively. They are encapsulation, inheritance, polymorphism, and abstraction.
The rest of this lesson will focus on how encapsulation works.
Encapsulation is the bundling of the attributes and methods of an object into a single unit, the class.
With encapsulation, you can hide the internal state of the object behind a simple set of public methods and attributes that act like doors. Behind those doors are private attributes and methods that control how the data changes and who can see it.
Let's say you want to track a wallet balance. You want to allow people to deposit or withdraw money from the wallet, but no one should be able to tamper with the balance directly.
In that case, you can make deposit() and withdraw() public methods, and you hide the balance under the _balance attribute:
class Wallet:
def __init__(self, balance):
self._balance = balance # For internal use by convention
def deposit(self, amount):
if amount > 0:
self._balance += amount # Add to the balance safely
def withdraw(self, amount):
if 0 < amount <= self._balance:
self._balance -= amount # Remove from the balance safely
By convention, prefixing attribute and methods with a single underscore means they are meant for internal use. No one should directly access them from outside the class since it defies the principles of encapsulation, which can lead to bugs.
While a single underscore prefix is just a convention, prefixing attributes and methods with a double underscore effectively prevents them to be accessed from the outside of their class, making those attributes and methods private.
class Wallet:
def __init__(self, balance):
self.__balance = balance # Private attribute
def deposit(self, amount):
if amount > 0:
self.__balance += amount # Add to the balance safely
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount # Remove from the balance safely
account = Wallet(500)
print(account.__balance) # AttributeError: 'Wallet' object has no attribute '__balance'
To get the current value of __balance, you can define a get_balance method. For example:
class Wallet:
def __init__(self, balance):
self.__balance = balance
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
def get_balance(self):
return self.__balance
acct_one = Wallet(100)
acct_one.deposit(50)
print(acct_one.get_balance()) # 150
acct_two = Wallet(450)
acct_two.withdraw(28)
print(acct_two.get_balance()) # 422
acct_two.deposit(150)
print(acct_two.get_balance()) # 572
You can also define a private __validate method to check if every deposit or withdrawal amount is a positive number:
class Wallet:
def __init__(self):
self.__balance = 0
def __validate(self, amount):
if amount < 0:
raise ValueError('Amount must be positive')
def deposit(self, amount):
self.__validate(amount)
self.__balance += amount
def withdraw(self, amount):
self.__validate(amount)
if amount > self.__balance:
raise ValueError('Insufficient funds')
self.__balance -= amount
def get_balance(self):
return self.__balance
acct_one = Wallet()
acct_one.deposit(3)
print(acct_one.get_balance()) # 3
acct_one.deposit(50)
print(acct_one.get_balance()) # 53
acct_one.deposit(-4) # ValueError: Amount must be positive
acct_one.withdraw(-8) # ValueError: Amount must be positive
acct_one.withdraw(58) # ValueError: Insufficient funds
As you can see, the __validate method is private, and runs behind the scenes in the deposit() and withdraw() public methods to make sure the amount is always valid.
In a coming lesson, you will learn more about how attributes prefixed with a double underscore works.
In summary, encapsulation locks down internal data behind clear public methods. That's how you keep your classes safe from tampering and centralize validation in one place. You can update or extend your code freely, knowing that outside code only touches the interfaces you expose.
Questions
What are the four key principles of Object-Oriented Programming (OOP)?
Encapsulation, Inheritance, Polymorphism, Abstraction
Variables, Functions, Loops, Conditionals
Classes, Objects, Methods, Attributes
Public, Private, Protected, Static
Why is it a bad practice to directly access attribute/methods prefixed with a single underscore?
Because Python will raise a syntax error if you try.
Because it violates encapsulation, allowing uncontrolled data access.
Because private properties are slower to access than public ones.
Because they can only be accessed from child classes.
What is the way for indicating private attributes or methods?
Prefix with a single underscore
Use leading and trailing double underscores
Prefix with a double underscore
Use the private keyword
Skip to content
Profile
Python Certification
Understanding Object Oriented Programming and Encapsulation
What are Getters and Setters
Getters and setters are methods that let you control how the attributes of a class are accessed and modified. With getters you retrieve a value, and with setters you set a value.
These actions are done through what's known as properties. They are what connect getters and setters, and allow access to data.
Properties act like attributes but behave like methods under the hood. Think of them as data you define like methods, but work like attributes. This means you can access properties with dot notation instead of parentheses or round brackets.
The main thing properties do is that they run extra logic behind the scenes when you get, set, or delete values with them. This makes them the perfect choice when you want to access or manipulate data within objects.
So why use properties for that instead of methods? It's mostly about readability and convention. They make your code cleaner and easier to read.
When you use a method, you always have to call it with parentheses. But with a property, you can access it just like a normal attribute using dot notation. That makes your code look simple even when it is doing extra work behind the scenes.
For example, you might want to calculate a value or check that a new value is valid before saving it. Instead of calling a method for that, you can use an attribute-like way to do that.
In Python, a decorator is a function that modifies the functionalities of other functions, or classes, without changing their original code.
While it is possible to create custom decorators, this is beyond the scope of this lesson. Just know that to create a property, you define a method and place the @property decorator above it. This turns the method into a property, so it can be accessed like an attribute while internally calling the decorated method.
That takes us to getters. Here's how to create one with the @property decorator:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self): # A getter to get the radius
return self._radius
@property
def area(self): # A getter to calculate area
return 3.14 * (self._radius ** 2)
my_circle = Circle(3)
print(my_circle.radius) # 3
print(my_circle.area) # 28.26
This example gets a radius and the area of a circle.
Notice how we used _radius instead of radius inside the class. The underscore is a common Python convention to show that an attribute is meant to be private. In other words, it signifies that it's for internal use and should not be accessed directly from outside the class.
To make a setter to create the radius, for example, you have to define another method with the same name and use @<property_name>.setter above it:
Using self.radius inside __init__ ensures the setter runs during object creation, so invalid radius values are caught immediately.
class Circle:
def __init__(self, radius):
self.radius = radius # Calling the setter
@property
def radius(self): # A getter to get the radius
return self._radius
@radius.setter
def radius(self, value): # A setter to set the radius
if value <= 0:
raise ValueError('Radius must be positive')
self._radius = value
my_circle = Circle(3)
print('Initial radius:', my_circle.radius) # Initial radius: 3
my_circle.radius = 8
print('After modifying the radius:', my_circle.radius) # After modifying the radius: 8
In this example, the radius setter is not just setting the radius for the circle, it's also running a validation that makes sure the radius is not a negative number.
Once you define getters and setters, Python automatically calls them under the hood whenever you use normal attribute syntax:
my_circle.radius # This will call the getter
my_circle.radius = 4 # This will call the setter
Note that inside the setter, you cannot use same name of the property when assigning a new value. That's because self.radius = value will call the setter within the setter method itself, leading to infinite recursion and a RecursionError. So you must always use the underscore-prefixed form self._radius = value.
Just like you can control how an attribute is accessed through getter and how it is modified with setter, you can also control how it is deleted using a deleter.
A deleter runs custom logic when you use the del statement on a property. To create one, you use the @<property_name>.deleter decorator:
class Circle:
def __init__(self, radius):
self.radius = radius
# Getter
@property
def radius(self):
return self._radius
# Setter
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
# Deleter
@radius.deleter
def radius(self):
print("Deleting radius...")
del self._radius
Here's how the deleter can be put to use:
# Create circle object with a radius
my_circle = Circle(33)
print("Initial radius:", my_circle.radius) # 33
# Delete the radius
# This calls the deleter
del my_circle.radius # Deleting radius...
print("Radius deleted!") # Radius deleted!
# Try to access radius after deletion
try:
print(my_circle.radius)
except AttributeError as e:
print("Error:", e) # Error: 'Circle' object has no attribute '_radius'
The takeaway from this is that:
Getters let you retrieve a value or even compute a value on the fly.
Setters let you modify the values safely by running checks before assignment.
Properties are what tie these getters and setters together so you can write logic while still using dot notation.
Deleters let you define what happens when an attribute is deleted.
Questions
What lets you run logic behind the scenes while getting or setting an attribute's value?
Importing external modules.
Class inheritance.
Properties
Direct attribute access.
What ties getters and setters together so you can execute logic while maintaining dot notation access?
Properties
Decorators
Class inheritance
Direct method calls
What two decorators are used to create getters and setters for a property?
@getter and @setter
@attr.get and @attr.set
@compute and @assign
@property and @<property_name>.setter
Skip to content
Profile
Python Certification
Understanding Inheritance and Polymorphism
What Is Inheritance and How Does It Promote Code Reuse?
Inheritance is the next key concept of object-oriented programming (OOP) we'll cover.
Let's take a deeper look at this concept and how it lets you write reusable code.
With inheritance, a subclass (or child class) can use the attributes and methods of a base class (or parent class). This allows you to reuse code, create clear class hierarchies, and customize behavior without rewriting everything. You can customize by extending existing methods or overriding them in the child class.
Here's the basic syntax for inheritance:
class Parent:
# Parent attributes and methods
class Child(Parent):
# Child inherits, extends, and/or overrides where necessary
For the Child class to inherit from the Parent class, you have to pass the Parent to the Child.
This style is called single inheritance, since a child class inherits from exactly one parent class.
Here's an example:
class Animal:
def __init__(self, name):
self.name = name
def sound(self):
return f'{self.name} makes a sound'
class Dog(Animal):
bark = 'woof! woof!! woof!!!'
jack = Dog('Jack')
print(jack.sound()) # Jack makes a sound
print(jack.bark) # woof! woof!! woof!!!
You can see that we're able to reuse the self.name attribute and the sound() method from the parent Animal class in the child Dog class.
Let's override the sound() method from the parent Animal class in the child Dog class so we can have sound() use the bark class variable:
class Animal:
def __init__(self, name):
self.name = name
def sound(self):
return f'{self.name} makes a sound.'
class Dog(Animal):
bark = 'woof! woof!! woof!!!'
# Override sound() to use bark class variable
def sound(self):
return f'{self.name} barks {self.bark}'
jack = Dog('Jack')
print(jack.sound()) # Jack barks woof! woof!! woof!!!
If you want to keep the return value of sound() and add the bark class variable later, you can extend sound() by using the super() function:
class Animal:
def __init__(self, name):
self.name = name
def sound(self):
return f'{self.name} makes a sound'
class Dog(Animal):
bark = 'woof! woof!! woof!!!'
# Call Animal.sound(), then append bark
def sound(self):
base = super().sound()
return f'{base}, then {self.name} barks {self.bark}'
jack = Dog('Jack')
print(jack.sound()) # Jack makes a sound, then Jack barks woof! woof!! woof!!!
In this example, base is the result of calling the sound() method from the Animal class, and then we append the Dog class's specific sound to it. This way, you can extend the functionality of the parent Animal class while still keeping its original behavior.
There's also multiple inheritance, where a child class can inherit from more than one parent class.
Here's the basic syntax of multiple inheritance:
class Parent:
# Attributes and methods for Parent
class Child:
# Attributes and methods for Child
class GrandChild(Parent, Child):
# GrandChild inherits from both Parent and Child
# GrandChild can combine or override behavior from each
A simple way to demonstrate multiple inheritance is with a frog, which can both walk on land and swim in water:
class Walker:
def walk(self):
return 'I can walk on land'
class Swimmer:
def swim(self):
return 'I can swim in water'
# Amphibian inherits from both Walker and Swimmer
class Amphibian(Walker, Swimmer):
def __init__(self, name):
self.name = name
def introduce(self):
return f"I'm {self.name} the frog. {self.walk()} and {self.swim()}."
frog = Amphibian('Freddy')
print(frog.introduce())
# Output: I'm Freddy the frog. I can walk on land and I can swim in water.
Questions
What is the primary purpose of inheritance in object-oriented programming?
To create identical copies of a parent class.
To allow a child class to reuse and extend attributes/methods from a parent class.
To make all methods in a class private.
To prevent code duplication by merging unrelated classes.
What is multiple inheritance in Python?
A child class inheriting from one parent class.
A parent class with multiple child classes.
A child class inheriting from multiple parent classes.
A class that cannot inherit at all.
How do you make a child class inherit from a parent class in Python?
By calling Parent.inherit(Child).
By copying all methods manually.
By using the extends keyword.
By passing the Parent class as an argument to the Child class.
Skip to content
Profile
Python Certification
Understanding Inheritance and Polymorphism
What Is Polymorphism and How Does It Promote Code Reuse?
Polymorphism is the next key concept of object-oriented programming (OOP) we will talk about.
With polymorphism, you have access to an interface where you can interact with many objects of the same kind.
Let's take a deeper look at polymorphism and how it lets you reuse code.
Polymorphism allows methods in different classes to share the same name but perform different tasks. You call the same method name on different objects, and each responds in its own way.
Here's the basic example of polymorphism:
class A:
def action(self): ...
class B:
def action(self): ...
class C:
def action(self): ...
Class().method() # Works for A, B, or C
Here's an example using different animal sounds to depict polymorphism:
class Cat:
def speak(self):
return "A cat meow"
class Bird:
def speak(self):
return "A bird tweet"
class Monkey:
def speak(self):
return "A monkey ooh ooh aah aah ooh ooh aah aah"
def animal_sound(animal):
print(animal.speak())
animal_sound(Cat())
animal_sound(Bird())
animal_sound(Monkey())
In this example, animal_sound() is a function that takes any object with a speak() method.
When you pass in a Cat, Bird, or Monkey, it calls the speak() method of the object and prints the result. Because each class defines speak() differently, you get different outputs from the same function. That's polymorphism in action.
Here's another example, this time with instances and an attribute:
class Twitter:
def __init__(self, content):
self.content = content
def post(self):
return f"🐦 Tweet: '{self.content}' (280 chars max)"
class Instagram:
def __init__(self, content):
self.content = content
def post(self):
return f"📸 Instagram Post: '{self.content}' + ✨ filters"
class LinkedIn:
def __init__(self, content):
self.content = content
def post(self):
return f"💼 LinkedIn Article: '{self.content}' (Professional Mode)"
def start(social_media):
print(social_media.post()) # Calls .post() on any object
# Instances
tweet = Twitter('Just learned Python polymorphism!')
photo = Instagram('Sunset vibes 🌅')
article = LinkedIn('Why OOP matters in 2024')
# The polymorphic calls - same function, different outputs
start(tweet) # 🐦 Tweet: 'Just learned Python polymorphism!' (280 chars max)
start(photo) # 📸 Instagram Post: 'Sunset vibes 🌅' + ✨ filters
start(article) # 💼 LinkedIn Article: 'Why OOP matters in 2024' (Professional Mode)
There's also a kind of polymorphism called inheritance-based polymorphism.
In inheritance-based polymorphism, a parent class defines a method, and multiple child classes override that method in their own way. You can then call the same method on any child object, and it behaves differently depending on which child class it is.
Here's an example:
class Animal:
def speak(self):
return 'Some generic sound'
class Cat(Animal):
def speak(self):
return 'A cat meow'
class Dog(Animal):
def speak(self):
return 'A dog barks woof woof'
class Monkey(Animal):
def speak(self):
return 'A monkey ooh ooh aah aah ooh ooh aah aah'
print(Cat().speak()) # A cat meow
print(Dog().speak()) # A dog barks woof woof
print(Monkey().speak()) # A monkey ooh ooh aah aah ooh ooh aah aah
print(Animal().speak()) # Some generic sound
You can see that each child class of the parent Animal class overrides the speak() method to provide its own implementation. So when you call the speak() method on an instance of each subclass, it returns the specific sound associated with that animal.
You can also take things further and do the calling in a list, then loop through the list to display what the speak() method returns for each:
animals = [Cat(), Dog(), Monkey()]
for animal in animals:
print(animal.speak())
# Output:
# A cat meow
# A dog barks woof woof
# A monkey ooh ooh aah aah ooh ooh aah aah
Questions
What is polymorphism in object-oriented programming?
When a class cannot have methods with the same name.
When different classes can use the same method name but implement it differently.
When a method must be unique across all classes.
When a child class deletes a parent class's method.
In inheritance-based polymorphism, what must a child class do to provide its own implementation of a method defined in the parent class?
Override the method by redefining it with the same name.
Declare the method as @static.
Delete the parent method first.
Use the @polymorphic decorator.
What is the key benefit of inheritance-based polymorphism?
It forces all child classes to implement identical behavior.
It prevents child classes from modifying parent methods.
It allows calling the same method name on different child objects, with each executing its own version.
It merges all child methods into the parent class.
Skip to content
Profile
Python Certification
Understanding Inheritance and Polymorphism
What is Name Mangling and How Does it Work?
In a previous lesson, you learned about prefixing attributes with a single underscore and a double underscore.
To remind you of the difference between them, a single underscore is a convention that means the attribute is meant for internal use in the class and should not be directly accessed from outside the class. Double underscore, on the other hand, prevents that attribute from being accessed directly from outside the class.
Here's an example that demonstrates how the two work:
class Example:
def __init__(self):
self._internal = 'I can be accessed from outside the class, but should not'
self.__private = 'You cannot access me directly from outside the class'
obj = Example()
print(obj._internal) # I can be accessed from outside the class, but should not
print(obj.__private) # AttributeError: 'Example' object has no attribute '__private'
Prefixing an attribute with a double underscore triggers Python's name mangling process, in which Python internally renames the attribute by adding an underscore and the class name as a prefix, turning __attribute into _ClassName__attribute.
To see this in action, you create an instance of the class and use the __dict__ special attribute of that instance, which is a dictionary containing the object's attributes:
class Example:
def __init__(self, internal, private):
self._internal = internal
self.__private = private
example1 = Example(
'I can be accessed from outside the class, but should not',
'I cannot be accessed directly from outside the class'
)
print(example1.__dict__)
The result would be:
{
'_internal': 'I can be accessed from outside the class, but should not',
'_Example__private': 'I cannot be accessed directly from outside the class'
}
As you can see, the __private attribute is stored as _Example__private. This means you can still access that attribute outside the class this way:
class Example:
def __init__(self, internal, private):
self._internal = internal
self.__private = private
example1 = Example(
'I can be accessed from outside the class, but should not',
'I cannot be accessed directly from outside the class'
)
example2 = Example(
'I should not be accessed from outside the class',
'But I can be accessed from outside the class with name mangling'
)
print(example1._Example__private) # I cannot be accessed directly from outside the class
print(example2._Example__private) # But I can be accessed from outside the class with name mangling
So, why does Python do name mangling?
The main purpose of name mangling is to prevent accidental attribute and method overriding when you use inheritance. Here's an example that makes that clear:
class Parent:
def __init__(self):
self.__data = 'Parent data'
class Child(Parent):
def __init__(self):
super().__init__()
self.__data = 'Child data'
c = Child()
print(c.__dict__) # {'_Parent__data': 'Parent data', '_Child__data': 'Child data'}
You can see that both the Parent class and the Child that inherits from it have their separate _class__data attributes. This is made possible with name mangling. Otherwise, the Child would have overwritten the Parent data by accident.
Here's what would have happened without allowing Python to do the name mangling, that is if you don't prefix the attributes in both classes with double underscore:
class Parent:
def __init__(self):
self.data = 'Parent data'
class Child(Parent):
def __init__(self):
super().__init__()
self.data = 'Child data'
c = Child()
print(c.__dict__) # {'data': 'Child data'}
So, which should you use to prefix attributes between single underscore (_) and double underscore (__)? It depends. If an attribute is only meant for internal use within the class, stick with a single underscore.
But if you're working with a class that will be inherited, you should use a double underscore so the attribute from the parent doesn't get overridden.
Questions
What is the difference between a single underscore and a double underscore?
A single underscore and a double underscore are treated the same way by Python.
A single underscore makes attributes completely private, while a double underscore makes them protected.
A single underscore prevents direct access, while a double underscore allows direct access.
A single underscore is just a convention, while a double underscore triggers name mangling.
What is name mangling?
A process in which Python converts all attributes into methods for easier access.
A process in which Python deletes attributes with a single underscore to keep them hidden.
A process in which Python changes __attribute into _ClassName__attribute to avoid accidental overriding in subclasses.
A process in which Python encrypts private data to make it inaccessible from outside the class.
What happens when you don't prefix attributes in a parent and child classes with a double underscore?
Both classes keep their own separate copies of the attribute without interfering with each other.
The child class completely overrides the parent class attribute, and the parent's data is lost.
The parent class attributes become read-only and cannot be changed by the child class.
Python raises an error because attributes must always be prefixed with a double underscore.
Skip to content
Profile
Python Certification
Understanding Abstraction
What Is Abstraction and How Does It Help Keep Complex Systems Organized?
Now that we've looked at encapsulation, inheritance, and polymorphism, let's discuss the next key concept of object-oriented programming – abstraction.
Abstraction is the process of hiding complex implementation details and showing only the essential features of an object or system. Think of it as focusing on what something does rather than how it does it.
Abstraction is not limited to Python. It's a programming concept that can be implemented in many languages that support object-oriented programming.
To illustrate abstraction, imagine you're driving a car. When you're in the driver's seat, you interact with essential parts like the steering wheel, shifter, and the accelerator and brake pedals. You don't need to know the intricate details of how engine works, how the transmission shifts gears, or the physics behind the braking system, as all of those are the complex implementation details.
That's an abstraction at work! It provides you with a simplified interface to interact with a complex system.
In the case of a car again, the simplified interface is the steering wheel, brakes, and accelerator, while the complex system is the car itself.
As for how Python implements abstraction, it does so through the abc module.
This module provides the ABC class (standing for “abstract base class”) and the @abstractmethod decorator.
ABC is the class that is meant to be inherited from, but you cannot create direct objects from it. It is what defines a common interface of methods and properties that its subclasses must implement.
On the other hand, an abstract method is a method declared in an Abstract Base Class (ABC) using the @abstractmethod decorator. It may have no implementation or a basic default one. However, any subclass must override it to be considered concrete and instantiable, even if a default implementation is provided.
Here's the basic syntax of abstract class in Python:
from abc import ABC, abstractmethod
# Define an abstract base class
class AbstractClass(ABC):
@abstractmethod
def abstract_method(self):
pass
# Concrete subclass that implements the abstract method
class ConcreteClassOne(AbstractClass):
def abstract_method(self):
print('Implementation in ConcreteClassOne')
# Another concrete subclass
class ConcreteClassTwo(AbstractClass):
def abstract_method(self):
print('Implementation in ConcreteClassTwo')
Here's a basic example:
from abc import ABC, abstractmethod
class Animal(ABC): # Inherits from abstract base class
@abstractmethod # Abstract method decorator
def make_sound(self): # The method subclasses must override
pass
# Concrete class that will override the abstract method
class Dog(Animal):
def make_sound(self):
print('Woof!')
# Another concrete class that will override the abstract method
class Cat(Animal):
def make_sound(self):
print('Meow!')
# Another concrete class that will override the abstract method
class Monkey(Animal):
def make_sound(self):
print('Ooh ooh aah aah!')
# Create instances of each concrete class
animals = [Dog(), Cat(), Monkey()]
# Loop through the instances to call the make_sound method
for animal in animals:
animal.make_sound()
# Output:
# Woof!
# Meow!
# Ooh ooh aah aah!
In this example:
We are importing the ABC class and abstractmethod from the abc module.
We then create an Animal class that inherits from ABC, and create an abstract method make_sound in it that each subclass of Animal must override.
We create the concrete classes Dog, Cat, and Monkey, which must override the make_sound abstract method.
We instantiate the concrete classes and call their make_sound method to show how each of them implements the make_sound abstract method in its own way.
Remember that you cannot create an instance of the Animal class. Here's what happens if you try to do that:
dog = Animal()
# TypeError: Can't instantiate abstract class Animal
# without an implementation for abstract method 'make_sound'
The same rule applies to subclasses that don't provide an implementation for the abstract method. Even if you define a subclass of Animal, you can't instantiate it until it overrides make_sound. Here's an example showing that:
class Bird(Animal):
pass
bird = Bird()
# TypeError: Can't instantiate abstract class Bird
# without an implementation for abstract method 'make_sound'
Here's another example, this time with an instance attribute you can pass to the instances of the concrete methods:
from abc import ABC, abstractmethod
# The blueprint for any toy that can speak
class TalkingToy(ABC):
def __init__(self, name):
self.name = name
@abstractmethod
def speak(self):
pass
class RobotToy(TalkingToy):
def speak(self):
print(f'{self.name} says beep boop! I am a robot!')
class TeddyBearToy(TalkingToy):
def speak(self):
print(f"{self.name} says hug me! I'm cuddly!")
class DinosaurToy(TalkingToy):
def speak(self):
print(f'{self.name} says ROOOOAR!')
# Create toys
rusty = RobotToy('Rusty')
fluffy = TeddyBearToy('Fluffy')
rex = DinosaurToy('Rex')
toys = [rusty, fluffy, rex]
for toy in toys:
toy.speak()
# Output:
# Rusty says beep boop! I am a robot!
# Fluffy says hug me! I'm cuddly!
# Rex says ROOOOAR!
In this example:
We have an abstract base class TalkingToy that defines a blueprint for any toy that can speak.
The subclasses RobotToy, TeddyBearToy, and DinosaurToy implement the speak method in their own way.
When we create instances of these subclasses and call the speak method, each toy speaks in its own unique way.
In conclusion, abstraction in Python simplifies complex systems by increasing reusability.
You've seen how you can reuse a single method from an abstract class across multiple subclasses while forcing each subclass to provide its specific behavior.
This approach keeps your code organized, flexible, and easier to maintain, especially as your application grows.
Questions
What is the primary goal of abstraction in object-oriented programming?
To expose all internal implementation details to users.
To merge multiple classes into one.
To prevent methods from being overridden in child classes.
To hide complex logic and only show essential features
How does Python implement abstraction through its ABC module?
By allowing direct instantiation of abstract classes.
By using decorators and inheritance to define abstract methods that subclasses must implement.
By automatically hiding all methods with double underscores.
By converting classes to functions.
In the car analogy for abstraction, what represents the simplified interface and the complex system?
The interface is the engine pistons, and the complex system is the steering wheel.
The interface is the steering wheel, brakes, and accelerator, and the complex system is the engine, transmission, and braking physics.
The interface is the car manual, and the complex system is the dashboard controls.
The interface is the fuel type, and the complex system is the tyres.
Skip to content
Profile
Python Certification
Working with Common Data Structures
What Is an Algorithm and How Does Big O Notation Work?
Every computer program that runs on your device has a specific set of instructions, which are executed in a specific order to complete a task.
The task could be sorting a set of numbers, modifying an image, tracking inventory, or even running your favorite video game.
This is where algorithms come into play. An algorithm is a set of unambiguous instructions for solving a problem or carrying out a task.
You can think of algorithms as "recipes". When you cook, recipes list all the ingredients that you'll need, and provide step by step instructions on how to prepare a dish.
Equivalently, you can think of algorithms as "recipes" that tell computers exactly what should be done and how to do it.
Algorithms have two key characteristics:
They cannot continue indefinitely. They must finish in a finite number of steps.
Each step must be precise and unambiguous.
They may have zero, one, or more inputs, and generate one or more outputs.
The steps of an algorithm are independent from any programming language.
But to actually make them run on a computer, you need to implement them in a programming language, like Python or JavaScript.
If an algorithm is correct, the output for any valid input should match the expected output.
In addition to being correct, algorithms should also be efficient.
Algorithm efficiency can be measured in terms of how long they take to run and how much space they require in memory to complete the task.
Knowing an algorithm's efficiency is very important because it gives you an idea of how well it will perform as the input size grows.
For example, sorting 15 integers is not the same as sorting 1 million integers.
As the process grows in size and complexity, if the algorithm is not efficient enough to handle it, you might end up with a very slow computer program that may even crash the entire system.
That's why it's very important to develop and choose the most efficient algorithms possible.
This is where Big O notation becomes very important.
Big O notation describes the worst-case performance, or growth rate, of an algorithm as the input size increases.
The growth rate of an algorithm refers to how the resources it requires increase as the input size grows.
Big O notation focuses on the worst-case performance because this case is very important to understand how efficient the algorithm can be, even in the worst case scenario, regardless of the input.
Going back to our sorting example, sorting 1 million integers should intuitively take more time and resources than sorting 15 integers.
But how much more?
This really depends on the algorithm that you choose to sort them.
Big O notation will not give you an exact number to describe the algorithm's efficiency, but it will give you an idea of how it scales as the input size grows, based on the number of operations performed by the algorithm.
In Big O notation, we usually denote input size with the letter n. For example, if the input is a list, n would denote the number of elements in that list.
Constant factors and lower-order terms are not taken into account to find the time complexity of an algorithm based on the number of operations. That's because as the size of n grows, the impact of these smaller terms in the total number of operations performed will become smaller and smaller.
The term that will dominate the overall behavior of the algorithm will be the highest order term with n, the input size.
For example, if an algorithm performs 7n + 20 operations to be completed, the impact of the constant 20 on the final result will be smaller and smaller as n grows. The term 7n will tend to dominate and this will define the overall behavior and efficiency of the algorithm.
Another example would be an algorithm that takes 20n² + 15n + 7 operations to be completed. The term 20n² will tend to dominate as n grows, so this algorithm would have a quadratic time complexity because the dominant term has n².
Quadratic time complexity is one of many different types of time complexities that you can find in the world of algorithms.
Let's learn about some of the most common ones.
O(1) is known as "Constant Time Complexity". When an algorithm has constant time complexity, it takes the same amount of time to run, regardless of input size.
For example, checking if a number is even or odd will always take the same amount of time, regardless of the number itself.
def check_even_or_odd(number):
if number % 2 == 0:
return 'Even'
else:
return 'Odd'
O(log n) is known as "Logarithmic Time Complexity". This means that the time required by the algorithm increases slowly as the input size grows. This is common in problems in which the size of the problem is repeatedly reduced by a constant fraction.
For example, a popular search algorithm called Binary Search has O(log n) worst-case time complexity. This is because it eliminates half of the remaining elements in each comparison, which makes it more efficient overall.
O(n) is known as "Linear Time Complexity". The running time of algorithms with this time complexity increases proportionally to the input size.
For example, a for loop that iterates over all the elements of a list will perform more iterations as the number of list elements increases. If the list is doubled in size, the number of operations will approximately double as well.
for grade in grades: # grades is a list.
print(grade)
O(n log n) is known as "Log-Linear Time Complexity". This is a common time complexity of efficient sorting algorithms, like Merge Sort and Quick Sort.