Free Python Course | Programming Tutorials
599 subscribers
9 photos
2 files
6 links
Join Noob to Pro in Python in 100 Days! πŸš€ Learn Python with daily lessons, practical examples, free code samples, exercises, and homework. Ideal for beginners and those looking to advance their skills. Start your journey to becoming a Python pro! 🐍
Download Telegram
Day 15: Encapsulation, Abstraction, and Properties in OOP
Welcome to Day 15 of the "Noob to Pro in Python" course! Today, we will explore two other essential concepts in Object-Oriented Programming (OOP): Encapsulation and Abstraction. These concepts help to hide the internal workings of a class, provide controlled access to data, and enhance the security and maintainability of your code. We'll also cover how to use properties to manage attribute access.
Encapsulation
Encapsulation is the concept of bundling data (attributes) and methods that operate on that data within a class and restricting direct access to some of the class’s components. This helps protect the object’s internal state and prevents outside interference.

Encapsulation in Python is achieved by making attributes private using name mangling. In Python, you can indicate that an attribute is private by prefixing its name with a double underscore __.

Example: Encapsulation with Private Attributes
class BankAccount:
def __init__(self, balance):
self.__balance = balance # Private attribute

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

# Creating an object
account = BankAccount(1000)

# Trying to access the private attribute directly
# print(account.__balance) # This will raise an AttributeError

# Using public methods to interact with the balance
account.deposit(500)
account.withdraw(200)
print(account.get_balance()) # Output: 1300

Here, __balance is a private attribute. It cannot be accessed directly from outside the class, but we can interact with it using public methods like deposit(), withdraw(), and get_balance().
Name Mangling
Although Python uses name mangling to prevent direct access to private attributes, you can still access them using a special syntax. This feature is primarily used to avoid accidental access rather than as a strict security measure.

Example: Accessing Private Attributes with Name Mangling
class BankAccount:
def __init__(self, balance):
self.__balance = balance

account = BankAccount(1000)
# Accessing the private attribute via name mangling
print(account._BankAccount__balance) # Output: 1000

This is not recommended in practice. Instead, access the data through methods provided by the class to maintain encapsulation.
Abstraction
Abstraction is the concept of hiding the implementation details of a class and only exposing the essential features. It allows users to interact with objects at a higher level without knowing how the internal details work.

In Python, abstraction is typically implemented through abstract classes and interfaces (using the abc module). Abstract classes cannot be instantiated and are meant to define a blueprint for other classes to follow.

Example: Abstract Class and Method
from abc import ABC, abstractmethod

class Shape(ABC): # Abstract base class
@abstractmethod
def area(self):
pass # Abstract method, must be overridden by subclasses

class Rectangle(Shape):
def __init__(self, length, width):
self.length = length
self.width = width

def area(self):
return self.length * self.width

class Circle(Shape):
def __init__(self, radius):
self.radius = radius

def area(self):
return 3.14 * self.radius ** 2

rect = Rectangle(5, 3)
circle = Circle(4)

print(f"Rectangle area: {rect.area()}") # Output: Rectangle area: 15
print(f"Circle area: {circle.area()}") # Output: Circle area: 50.24

In this example:
Shape is an abstract class that defines an abstract method area().
Subclasses Rectangle and Circle implement the area() method as per their specific behavior.
You cannot instantiate the Shape class directly, but you can work with its subclasses.
Properties in Python
Properties in Python provide a way to control access to instance attributes. They allow you to define methods that get or set the value of an attribute, but access it as if it were a regular attribute. This provides a clean interface to your class and ensures that any necessary validation or logic can be applied when accessing or modifying attributes.

Example: Using the property() Decorator
class Employee:
def __init__(self, name, salary):
self.__name = name
self.__salary = salary

# Getter method for 'salary'
@property
def salary(self):
return self.__salary

# Setter method for 'salary'
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError("Salary cannot be negative.")
self.__salary = value

# Creating an Employee object
emp = Employee("Alice", 5000)

# Accessing the salary using the getter method
print(emp.salary) # Output: 5000

# Setting a new value for salary using the setter method
emp.salary = 6000
print(emp.salary) # Output: 6000

# Trying to set a negative salary (will raise an exception)
# emp.salary = -1000 # Raises ValueError: Salary cannot be negative.

Here, the @property decorator turns the salary() method into a getter for the __salary attribute. The @salary.setter decorator allows controlled modification of the __salary attribute with validation.
πŸ‘1
Benefits of Encapsulation and Abstraction

Encapsulation protects data by restricting direct access and ensures that changes to attributes are controlled.

Abstraction hides the implementation details, allowing users to interact with objects at a higher level without needing to know the internal workings.

Properties in Python provide a clean and efficient way to manage access to class attributes, ensuring that data validation and other logic can be applied when necessary.
Practice Exercises for Day 15

Exercise 1: Encapsulation

Create a class Person with private attributes name and age. Provide methods to get and set the values of these attributes while ensuring the age is always positive.

Exercise 2: Abstraction
Create an abstract class Appliance with an abstract method turn_on(). Implement two subclasses WashingMachine and Fridge, each overriding the turn_on() method to print a message.

Exercise 3: Properties
Write a class Temperature with a private attribute celsius. Use the property() decorator to create a getter and setter for celsius and a getter for fahrenheit, converting Celsius to Fahrenheit.

Exercise 4: Name Mangling
Create a class Car with private attributes brand and speed. Write methods to increase or decrease speed, ensuring the speed cannot be negative. Access the private attributes using name mangling.
8. Summary

In Day 15, we covered:

- Encapsulation, which helps protect the internal state of objects by restricting direct access to certain attributes.
- Name mangling, which is used to create private attributes that can’t be accessed directly.
- Abstraction, which hides the implementation details and allows users to interact with a simplified interface.
- Abstract classes and methods, which define a common interface for different subclasses.
- Properties, which provide a clean way to manage attribute access and ensure proper validation.

Encapsulation and abstraction are critical principles for building robust, maintainable code, and properties provide a powerful mechanism to manage access to your class’s data. Keep practicing to solidify your understanding!
By mistake I missed some topicsπŸ‘‡
1. Decorators - Introduction, Use Cases
2. Python Built-in Functions (zip(), enumerate(), etc.)
3. Regular Expressions (regex)

Don't worry I will cover itπŸ‘
❀2
Day 16: Decorators - Introduction and Use Cases
Welcome to Day 16! Today, we’ll dive into decorators, one of Python’s most powerful and useful features. By the end of this lesson, you will understand how decorators work, when to use them, and how they can improve your code.
πŸ‘1
What is a Decorator?
A decorator in Python is a function that takes another function as an argument, adds some functionality to it, and returns it, without modifying the original function’s structure.

Why Use Decorators?
Code Reusability: You can reuse the same code across multiple functions by applying the same decorator.
Separation of Concerns: Decorators allow you to separate different functionalities like logging, access control, etc.
Clean Code: By using decorators, you avoid cluttering the core functionality of your functions with extra responsibilities.
πŸ‘1
Basic Structure of a Decorator
A decorator wraps around a function to extend or alter its behavior. Here's the basic structure of a decorator.

Example: Basic Decorator
def my_decorator(func):
def wrapper():
print("Something before the function runs.")
func()
print("Something after the function runs.")
return wrapper

@my_decorator
def say_hello():
print("Hello!")

say_hello()

Explanation:
my_decorator is the decorator function.
@my_decorator is the syntax to apply the decorator to the say_hello() function.
When you call say_hello(), the decorator adds functionality before and after it.

Output:
Something before the function runs.
Hello!
Something after the function runs.
Decorators with Arguments
Sometimes, the function being decorated accepts arguments. To handle this, we modify the decorator to accept any number of arguments using *args and **kwargs.

Example: Decorator with Arguments
def my_decorator(func):
def wrapper(*args, **kwargs):
print(f"Function called with arguments: {args}, {kwargs}")
return func(*args, **kwargs)
return wrapper

@my_decorator
def greet(name, age):
print(f"Hello {name}, you are {age} years old.")

greet("Alice", 25)


Output:
Function called with arguments: ('Alice', 25), {}
Hello Alice, you are 25 years old.
πŸ‘1
Real-World Use Cases for Decorators
Decorators are extremely useful in a variety of scenarios:
Logging: Track when functions are called and with what parameters.
Timing Functions: Measure how long a function takes to execute.
Authentication and Access Control: Ensure that users have permission to execute certain functions.
Memoization: Cache the results of expensive functions to avoid redundant calculations.
Use Case: Logging with Decorators
A common use of decorators is to log information about when and how a function is used.

Example: Logging Decorator
def log_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling function '{func.__name__}' with arguments {args} and {kwargs}")
return func(*args, **kwargs)
return wrapper

@log_decorator
def add(a, b):
return a + b

result = add(10, 20)
print(f"Result: {result}")


Output:
Calling function 'add' with arguments (10, 20) and {}
Result: 30

This decorator logs the function name and its arguments before calling the function.
πŸ‘1
Use Case: Timing with Decorators
Another practical use of decorators is to measure how long a function takes to execute.

Example: Timing Decorator
import time

def time_decorator(func):
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Function '{func.__name__}' executed in {end_time - start_time:.4f} seconds")
return result
return wrapper

@time_decorator
def long_running_function():
time.sleep(2)
print("Function completed.")

long_running_function()


Output:
Function completed.
Function 'long_running_function' executed in 2.0002 seconds

In this example, the decorator calculates the time taken for the function to execute and prints it.
πŸ‘1
Use Case: Access Control with Decorators
Decorators can be used to restrict access to certain functions based on conditions like user authentication.

Example: Authentication Decorator
def requires_authentication(func):
def wrapper(user):
if user.get('authenticated'):
return func(user)
else:
print("Access Denied: User not authenticated.")
return wrapper

@requires_authentication
def view_dashboard(user):
print(f"Welcome, {user['name']}!")

user1 = {'name': 'Alice', 'authenticated': True}
user2 = {'name': 'Bob', 'authenticated': False}

view_dashboard(user1) # Welcome, Alice!
view_dashboard(user2) # Access Denied: User not authenticated.

In this case, the decorator checks whether the user is authenticated before allowing access to the view_dashboard() function.
πŸ‘1
Combining Multiple Decorators
You can apply multiple decorators to the same function. When multiple decorators are applied, they are executed from the bottom to the top (closest to the function first).

Example: Multiple Decorators
def decorator_one(func):
def wrapper():
print("Decorator One")
return func()
return wrapper

def decorator_two(func):
def wrapper():
print("Decorator Two")
return func()
return wrapper

@decorator_one
@decorator_two
def greet():
print("Hello!")

greet()


Output:
Decorator One
Decorator Two
Hello!
πŸ‘1πŸ₯°1
Practice Exercises for Day 16

Exercise 1: Simple Logging Decorator

Create a decorator that logs the execution of a function, including its name and the arguments passed.

Exercise 2: Timing Function
Write a decorator that times how long a function takes to execute and prints the execution time.

Exercise 3: Simple Authentication
Write a decorator that checks if a user is authenticated. If not, it should print "Access denied."

Exercise 4: Memoization Decorator
Write a decorator that caches the results of a function. If the function is called with the same arguments again, return the cached result instead of recomputing it.