Creating well-designed classes not only streamlines complexity and enhances code comprehension but also significantly contributes to a system’s scalability and robustness. By adhering to principles of good class design, developers can ensure that their software remains flexible in the face of changing requirements, thereby facilitating easier updates and extensions. Today I’ll be exploring this approach which aligns with modern software development practices that prioritize adaptability, performance, and maintainability.
from dataclasses import dataclass
from email.message import EmailMessage
from smtplib import SMTP_SSL
SMTP_SERVER = "smtp.gmail.com"
PORT = 465
EMAIL = "hi@arjancodes.com"
PASSWORD = "password"
@dataclass
class Person:
name: str
age: int
address_line_1: str
address_line_2: str
city: str
country: str
postal_code: str
email: str
phone_number: str
gender: str
height: float
weight: float
blood_type: str
eye_color: str
hair_color: str
def split_name(self) -> tuple[str, str]:
first_name, last_name = self.name.split(" ")
return first_name, last_name
def get_full_address(self) -> str:
return f"{self.address_line_1}, {self.address_line_2}, {self.city}, {self.country}, {self.postal_code}"
def get_bmi(self) -> float:
return self.weight / (self.height**2)
def get_bmi_category(self) -> str:
if self.get_bmi() < 18.5:
return "Underweight"
elif self.get_bmi() < 25:
return "Normal"
elif self.get_bmi() < 30:
return "Overweight"
else:
return "Obese"
def update_email(self, email: str) -> None:
self.email = email
# send email to the new address
msg = EmailMessage()
msg.set_content(
"Your email has been updated. If this was not you, you have a problem."
)
msg["Subject"] = "Your email has been updated."
msg["To"] = self.email
with SMTP_SSL(SMTP_SERVER, PORT) as server:
# server.login(EMAIL, PASSWORD)
# server.send_message(msg, EMAIL)
pass
print("Email sent successfully!")
def main() -> None:
# create a person
person = Person(
name="John Doe",
age=30,
address_line_1="123 Main St",
address_line_2="Apt 1",
city="New York",
country="USA",
postal_code="12345",
email="johndoe@gmail.com",
phone_number="123-456-7890",
gender="Male",
height=1.8,
weight=80,
blood_type="A+",
eye_color="Brown",
hair_color="Black",
)
# compute the BMI
bmi = person.get_bmi()
print(f"Your BMI is {bmi:.2f}")
print(f"Your BMI category is {person.get_bmi_category()}")
# update the email address
person.update_email("johndoe@outlook.com")
if __name__ == "__main__":
main()
Simplifying class structures
Tackling large classes that have multiple responsibilities can be quite the talk, and almost always will make it more difficult for developer to understand, test and maintain. When a class has too many responsibilities it becomes prone to errors and bugs. This complexity tends to obfuscate the implementation and intention of the class, they can also hide other issues such as those affecting performance and overall the code becomes highly delicate to change.
To tackle these issues, it’s a good idea to consider breaking down monolithic classes into smaller, more manageable components, where each component focuses on a single responsibility. This approach helps organize the code, making it clearer and reducing redundancy. Smaller classes are much easier to understand and test, which enhances the overall quality and maintainability of the code.
Improving usability
To enhance the user-friendliness of classes, it’s crucial to focus on developing an intuitive interface for their attributes and methods. An intuitive interface involves designing elements that clearly convey their purposes and applications. This clarity and consistency in design make the classes more accessible and contribute to a coherent code structure.
As Guido himself said, code is more often read than written. When code is well structured, includes type hints, and has a clear purpose, it becomes much easier to reason about and utilize.
Furthermore, it’s essential to ensure that classes align with the overall structure of the codebase. This facilitates seamless integration with other components, improving the efficiency and maintainability of the entire system. When classes are designed to fit within a larger ecosystem, it streamlines development processes and encourages a modular approach to software construction. This modularity allows for easier updates, debugging, and scalability, as well-designed classes can be reused and adapted without compromising the system’s integrity. Hence, the emphasis on intuitive interfaces and alignment with the codebase highlights the significance of thoughtful class design in creating user-friendly and cohesive software solutions.
Implementing dependency injection
Adopting a strategy where dependencies are passed as parameters to classes instead of being hardcoded within them can greatly enhance the modularity and adaptability of software systems. This approach, known as dependency injection, promotes a more flexible design architecture, making it easier to reuse and reconfigure components with minimal effort. This is particularly advantageous in complex systems where dependencies can vary significantly across different environments or use cases. Externalizing dependencies allows developers to swap out implementations effortlessly without needing to make changes to the class internals, resulting in a cleaner and more loosely coupled codebase.
Furthermore, this shift towards dependency injection simplifies the software testing process. When dependencies are injected, it becomes much easier to introduce mock objects or stubs in place of real dependencies during testing phases. This speeds up the testing process by eliminating the need for intricate setup or access to external resources and improving test reliability and granularity. Test cases become more focused, targeting specific behaviors without being entangled in the complexities of the actual dependencies. As a result, developers can achieve more comprehensive test coverage with less effort, leading to higher-quality software that is more resilient to changes and easier to maintain over time.
Limiting public interfaces
Designing classes to only reveal what’s necessary for the user can be done by using _ and ** prefixes to denote private members in Python. It should be noted that the ** prefix is not a security feature; it only changes the name to avoid conflicts. Following the convention of using the _ prefix is considered good practice and relies on the community to respect these boundaries. It’s advisable to use immutable data structures for attributes that should not be modified after initialization. This approach helps prevent accidental modifications and simplifies the testing process.
Choosing the right structure
While classes are powerful, they may not always be the best choice for every programming task. Sometimes, using modules is more suitable, especially when you have a group of static functions that do not need to share or manipulate an object’s state. Modules act as containers for functions that can be reused throughout your code, allowing you to logically organize functions without the extra complexity of a class. For more complex applications you can also group modules into packages. It’s often good practice to group your modules by service or feature.
Moreover, classes might be unnecessary when their sole purpose is to store data. In such cases, simpler data structures like dictionaries, sets, or tuples can often be more efficient. For example, dictionaries are perfect for storing key-value pairs, sets are great for holding an unordered collection of unique items, and tuples can store a fixed set of items. Opting for these data structures can simplify your code, making it easier to read and maintain. This approach to selecting the appropriate data structure or coding pattern can greatly improve the performance and scalability of your software.
from dataclasses import dataclass
from functools import lru_cache, partial
from typing import Protocol
from email.message import EmailMessage
from smtplib import SMTP_SSL
SMTP_SERVER = "smtp.gmail.com"
PORT = 465
EMAIL = "hi@arjancodes.com"
PASSWORD = "password"
def create_email_message(to_email: str, subject: str, body: str) -> EmailMessage:
msg = EmailMessage()
msg.set_content(body)
msg["Subject"] = subject
msg["To"] = to_email
return msg
def send_email(
smtp_server: str,
port: int,
email: str,
password: str,
to_email: str,
subject: str,
body: str,
) -> None:
msg = create_email_message(to_email, subject, body)
with SMTP_SSL(smtp_server, port) as server:
# server.login(email, password)
# server.send_message(msg, email)
print("Email sent successfully!")
class EmailSender(Protocol):
def __call__(self, to_email: str, subject: str, body: str) -> None:
...
@lru_cache
def bmi(weight: float, height: float) -> float:
return weight / (height**2)
def bmi_category(bmi_value: float) -> str:
if bmi_value < 18.5:
return "Underweight"
elif bmi_value < 25:
return "Normal"
elif bmi_value < 30:
return "Overweight"
else:
return "Obese"
@dataclass
class Stats:
age: int
gender: str
height: float
weight: float
blood_type: str
eye_color: str
hair_color: str
@dataclass
class Address:
address_line_1: str
address_line_2: str
city: str
country: str
postal_code: str
def __str__(self) -> str:
return f"{self.address_line_1}, {self.address_line_2}, {self.city}, {self.country}, {self.postal_code}"
@dataclass
class Person:
name: str
address: Address
email: str
phone_number: str
stats: Stats
def split_name(self) -> tuple[str, str]:
first_name, last_name = self.name.split(" ")
return first_name, last_name
def update_email(self, email: str, send_message: EmailSender) -> None:
self.email = email
send_message(
to_email=email,
subject="Your email has been updated.",
body="Your email has been updated. If this was not you, you have a problem.",
)
def main() -> None:
# create a person
address = Address(
address_line_1="123 Main St",
address_line_2="Apt 1",
city="New York",
country="USA",
postal_code="12345",
)
stats = Stats(
age=30,
gender="Male",
height=1.8,
weight=80,
blood_type="A+",
eye_color="Brown",
hair_color="Black",
)
person = Person(
name="John Doe",
email="johndoe@gmail.com",
phone_number="123-456-7890",
address=address,
stats=stats,
)
print(address)
bmi_value = bmi(stats.weight, stats.height)
print(f"Your BMI is {bmi_value:.2f}")
print(f"Your BMI category is {bmi_category(bmi_value)}")
send_message = partial(
send_email, smtp_server=SMTP_SERVER, port=PORT, email=EMAIL, password=PASSWORD
)
person.update_email("johndoe@outlook.com", send_message)
if __name__ == "__main__":
main()
Final thoughts
The well-thought-out design of classes is really important when it comes to efficient software development. By following these principles, you can make your classes more user-friendly, easier to maintain, and simpler to test. If you want to learn more about improving your function design, you should definitely check out Optimize Python Code for Better Maintenance.