Files
practical-python/Notes/05_Object_model/02_Classes_encapsulation.md
A D Vishnu Prasad bd46830659 Fix Broken Links
2020-05-29 19:15:25 +05:30

357 lines
9.1 KiB
Markdown

[Contents](../Contents.md) \| [Previous (5.1 Dictionaries Revisited)](01_Dicts_revisited) \| [Next (6 Generators)](../06_Generators/00_Overview)
# 5.2 Classes and Encapsulation
When writing classes, it is common to try and encapsulate internal details.
This section introduces a few Python programming idioms for this including
private variables and properties.
### Public vs Private.
One of the primary roles of a class is to encapsulate data and internal
implementation details of an object. However, a class also defines a
*public* interface that the outside world is supposed to use to
manipulate the object. This distinction between implementation
details and the public interface is important.
### A Problem
In Python, almost everything about classes and objects is *open*.
* You can easily inspect object internals.
* You can change things at will.
* There is no strong notion of access-control (i.e., private class members)
That is an issue when you are trying to isolate details of the *internal implementation*.
### Python Encapsulation
Python relies on programming conventions to indicate the intended use
of something. These conventions are based on naming. There is a
general attitude that it is up to the programmer to observe the rules
as opposed to having the language enforce them.
### Private Attributes
Any attribute name with leading `_` is considered to be *private*.
```python
class Person(object):
def __init__(self, name):
self._name = 0
```
As mentioned earlier, this is only a programming style. You can still
access and change it.
```python
>>> p = Person('Guido')
>>> p._name
'Guido'
>>> p._name = 'Dave'
>>>
```
As a general rule, any name with a leading `_` is considered internal implementation
whether it's a variable, a function, or a module name. If you find yourself using such
names directly, you're probably doing something wrong. Look for higher level functionality.
### Simple Attributes
Consider the following class.
```python
class Stock:
def __init__(self, name, shares, price):
self.name = name
self.shares = shares
self.price = price
```
A surprising feature is that you can set the attributes
to any value at all:
```python
>>> s = Stock('IBM', 50, 91.1)
>>> s.shares = 100
>>> s.shares = "hundred"
>>> s.shares = [1, 0, 0]
>>>
```
You might look at that and think you want some extra checks.
```python
s.shares = '50' # Raise a TypeError, this is a string
```
How would you do it?
### Managed Attributes
One approach: introduce accessor methods.
```python
class Stock:
def __init__(self, name, shares, price):
self.name = name self.set_shares(shares) self.price = price
# Function that layers the "get" operation
def get_shares(self):
return self._shares
# Function that layers the "set" operation
def set_shares(self, value):
if not isinstance(value, int):
raise TypeError('Expected an int')
self._shares = value
```
Too bad that this breaks all of our existing code. `s.shares = 50`
becomes `s.set_shares(50)`
### Properties
There is an alternative approach to the previous pattern.
```python
class Stock:
def __init__(self, name, shares, price):
self.name = name
self.shares = shares
self.price = price
@property
def shares(self):
return self._shares
@shares.setter
def shares(self, value):
if not isinstance(value, int):
raise TypeError('Expected int')
self._shares = value
```
Normal attribute access now triggers the getter and setter methods
under `@property` and `@shares.setter`.
```python
>>> s = Stock('IBM', 50, 91.1)
>>> s.shares # Triggers @property
50
>>> s.shares = 75 # Triggers @shares.setter
>>>
```
With this pattern, there are *no changes* needed to the source code.
The new *setter* is also called when there is an assignment within the class,
including inside the `__init__()` method.
```python
class Stock:
def __init__(self, name, shares, price):
...
# This assignment calls the setter below
self.shares = shares
...
...
@shares.setter
def shares(self, value):
if not isinstance(value, int):
raise TypeError('Expected int')
self._shares = value
```
There is often a confusion between a property and the use of private names.
Although a property internally uses a private name like `_shares`, the rest
of the class (not the property) can continue to use a name like `shares`.
Properties are also useful for computed data attributes.
```python
class Stock:
def __init__(self, name, shares, price):
self.name = name
self.shares = shares
self.price = price
@property
def cost(self):
return self.shares * self.price
...
```
This allows you to drop the extra parantheses, hiding the fact that it's actually a method:
```python
>>> s = Stock('GOOG', 100, 490.1)
>>> s.shares # Instance variable
100
>>> s.cost # Computed Value
49010.0
>>>
```
### Uniform access
The last example shows how to put a more uniform interface on an object.
If you don't do this, an object might be confusing to use:
```python
>>> s = Stock('GOOG', 100, 490.1)
>>> a = s.cost() # Method
49010.0
>>> b = s.shares # Data attribute
100
>>>
```
Why is the `()` required for the cost, but not for the shares? A property
can fix this.
### Decorator Syntax
The `@` syntax is known as *decoration". It specifies a modifier
that's applied to the function definition that immediately follows.
```python
...
@property
def cost(self):
return self.shares * self.price
```
More details are given in [Section 7](../07_Advanced_Topics/00_Overview).
### `__slots__` Attribute
You can restrict the set of attributes names.
```python
class Stock:
__slots__ = ('name','_shares','price')
def __init__(self, name, shares, price):
self.name = name
...
```
It will raise an error for other attributes.
```python
>>> s.price = 385.15
>>> s.prices = 410.2
Traceback (most recent call last):
File "<stdin>", line 1, in ?
AttributeError: 'Stock' object has no attribute 'prices'
```
Although this prevents errors and restricts usage of objects, it's actually used for performance and
makes Python use memory more efficiently.
### Final Comments on Encapsulation
Don't go overboard with private attributes, properties, slots,
etc. They serve a specific purpose and you may see them when reading
other Python code. However, they are not necessary for most
day-to-day coding.
## Exercises
### Exercise 5.6: Simple Properties
Properties are a useful way to add "computed attributes" to an object.
In `stock.py`, you created an object `Stock`. Notice that on your
object there is a slight inconsistency in how different kinds of data
are extracted:
```python
>>> from stock import Stock
>>> s = Stock('GOOG', 100, 490.1)
>>> s.shares
100
>>> s.price
490.1
>>> s.cost()
49010.0
>>>
```
Specifically, notice how you have to add the extra () to `cost` because it is a method.
You can get rid of the extra () on `cost()` if you turn it into a property.
Take your `Stock` class and modify it so that the cost calculation works like this:
```python
>>> ================================ RESTART ================================
>>> from stock import Stock
>>> s = Stock('GOOG', 100, 490.1)
>>> s.cost
49010.0
>>>
```
Try calling `s.cost()` as a function and observe that it
doesn't work now that `cost` has been defined as a property.
```python
>>> s.cost()
... fails ...
>>>
```
Making this change will likely break your earlier `pcost.py` program.
You might need to go back and get rid of the `()` on the `cost()` method.
### Exercise 5.7: Properties and Setters
Modify the `shares` attribute so that the value is stored in a
private attribute and that a pair of property functions are used to ensure
that it is always set to an integer value. Here is an example of the expected
behavior:
```python
>>> ================================ RESTART ================================
>>> from stock import Stock
>>> s = Stock('GOOG',100,490.10)
>>> s.shares = 50
>>> s.shares = 'a lot'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: expected an integer
>>>
```
### Exercise 5.8: Adding slots
Modify the `Stock` class so that it has a `__slots__` attribute. Then,
verify that new attributes can't be added:
```python
>>> ================================ RESTART ================================
>>> from stock import Stock
>>> s = Stock('GOOG', 100, 490.10)
>>> s.name
'GOOG'
>>> s.blah = 42
... see what happens ...
>>>
```
When you use `__slots__`, Python uses a more efficient
internal representation of objects. What happens if you try to
inspect the underlying dictionary of `s` above?
```python
>>> s.__dict__
... see what happens ...
>>>
```
It should be noted that `__slots__` is most commonly used as an
optimization on classes that serve as data structures. Using slots
will make such programs use far-less memory and run a bit faster.
You should probably avoid `__slots__` on most other classes however.
[Contents](../Contents.md) \| [Previous (5.1 Dictionaries Revisited)](01_Dicts_revisited) \| [Next (6 Generators)](../06_Generators/00_Overview)