Summary Notes
Object-Oriented Programming (OOP) in Python
OOP is a programming paradigm that organizes code around objects and classes. Python supports OOP with full features including inheritance, polymorphism, encapsulation, and abstraction.
Key Concepts:
- Class: A blueprint for creating objects
- Object: An instance of a class
- Attributes: Variables that belong to a class or object
- Methods: Functions that belong to a class
- Constructor: __init__ method called when object is created
- self: Reference to the current instance of the class
Inheritance: Allows a class to inherit attributes and methods from another class
- Parent/Super Class: The class being inherited from
- Child/Sub Class: The class that inherits
- super(): Function to call parent class methods
Polymorphism: Ability of different classes to be treated as instances of the same class through inheritance
Encapsulation: Hiding internal details and protecting data
- Public: Accessible from anywhere
- Protected: Accessible within class and subclasses (_attribute)
- Private: Accessible only within class (__attribute)
Special Methods:
__str__()- String representation__repr__()- Official string representation__len__()- Length of object__getitem__()- Access items like lists__setitem__()- Set items like lists__add__()- Addition operator__eq__()- Equality comparison
Exception Handling
Exceptions are errors that occur during program execution. Python provides robust exception handling to manage runtime errors gracefully.
Exception Hierarchy:
- BaseException: Root of all exceptions
- Exception: Base for all built-in exceptions
- StandardError: Deprecated, replaced by Exception
Common Built-in Exceptions:
ValueError- Invalid value for operationTypeError- Operation on incompatible typesIndexError- Index out of rangeKeyError- Dictionary key not foundFileNotFoundError- File not foundZeroDivisionError- Division by zeroAttributeError- Attribute doesn't existImportError- Import failedIOError- Input/Output operation failed
Exception Handling Structure:
try:
# Code that might raise exception
except ExceptionType:
# Handle specific exception
except (ExceptionType1, ExceptionType2):
# Handle multiple exceptions
else:
# Execute if no exception
finally:
# Always execute
Raising Exceptions:
raise ExceptionType("message")- Raise exceptionraise- Re-raise current exception
Custom Exceptions: Create by inheriting from Exception class
Algorithms and Data Structures
Algorithms are step-by-step procedures for solving problems. Understanding algorithms is crucial for efficient programming.
Sorting Algorithms:
- Bubble Sort: Simple comparison-based sort, O(n²) time
- Selection Sort: Find minimum and swap, O(n²) time
- Insertion Sort: Build sorted array one element at a time, O(n²) time
- Merge Sort: Divide and conquer, O(n log n) time
- Quick Sort: Divide and conquer with pivot, O(n log n) average
- TimSort: Hybrid of merge and insertion sort, used in Python
Searching Algorithms:
- Linear Search: Check each element sequentially, O(n) time
- Binary Search: Divide and conquer on sorted data, O(log n) time
- Hash Table Search: Direct access using keys, O(1) average
Algorithm Analysis:
- Time Complexity: How execution time grows with input size
- Space Complexity: How memory usage grows with input size
- Big O Notation: Upper bound of growth rate
- Best/Average/Worst Case: Performance in different scenarios
Common Data Structures:
- Stack: LIFO (Last In, First Out)
- Queue: FIFO (First In, First Out)
- Linked List: Elements connected by pointers
- Tree: Hierarchical structure
- Graph: Nodes connected by edges
- Hash Table: Key-value mapping
Data Analysis with NumPy and Pandas
NumPy and Pandas are essential libraries for data manipulation and analysis in Python.
NumPy:
- ndarray: N-dimensional array object
- Vectorized Operations: Element-wise operations without loops
- Broadcasting: Operations on arrays of different shapes
- Mathematical Functions: sin, cos, exp, log, etc.
- Linear Algebra: Matrix operations, eigenvalues, etc.
- Random Number Generation: Various distributions
Array Creation:
np.array([1, 2, 3])- From listnp.zeros((3, 4))- Array of zerosnp.ones((2, 3))- Array of onesnp.arange(0, 10, 2)- Range with stepnp.linspace(0, 1, 5)- Evenly spaced valuesnp.random.rand(3, 3)- Random values
Pandas:
- Series: 1D labeled array
- DataFrame: 2D labeled data structure
- Data Manipulation: Filtering, grouping, merging
- Data Cleaning: Handling missing values, duplicates
- Data Import/Export: CSV, Excel, SQL, JSON
- Time Series: Date/time handling
DataFrame Operations:
df.head()- First few rowsdf.describe()- Statistical summarydf.groupby()- Group operationsdf.merge()- Join DataFramesdf.pivot_table()- Pivot tablesdf.dropna()- Remove missing values
Matplotlib: Plotting library for data visualization
- Line Plots: plt.plot()
- Bar Charts: plt.bar()
- Histograms: plt.hist()
- Scatter Plots: plt.scatter()
- Subplots: plt.subplot()
- Styling: Colors, markers, labels
Q&A
- What is a class in Python?
- A class is a blueprint for creating objects. It defines attributes and methods that objects of that class will have.
- How to handle exceptions?
- Use try-except blocks. Put potentially error-causing code in try block, and error handling in except block.
- What is NumPy used for?
- NumPy is used for numerical computing in Python. It provides support for large, multi-dimensional arrays and matrices, along with mathematical functions to operate on them.
- How to create a Pandas DataFrame?
- Use pd.DataFrame(data) where data can be a dictionary, list, or other data structure.
- What is inheritance in OOP?
- Inheritance allows a class (child) to inherit attributes and methods from another class (parent), promoting code reuse.
- What is polymorphism?
- Polymorphism allows objects of different classes to be treated as objects of a common superclass, enabling method overriding.
- How do you raise an exception?
- Use the raise keyword: raise ExceptionType("error message").
- What is the difference between __str__ and __repr__?
- __str__ is for user-friendly string representation, __repr__ is for unambiguous representation used by developers.
- What is Big O notation?
- Big O notation describes the upper bound of an algorithm's time or space complexity as input size grows.
- How does NumPy broadcasting work?
- Broadcasting allows operations on arrays of different shapes by automatically expanding smaller arrays to match larger ones.
- What is a Pandas Series?
- A Series is a one-dimensional labeled array in Pandas, similar to a column in a spreadsheet.
- How to handle missing data in Pandas?
- Use methods like dropna() to remove missing values, fillna() to fill them, or isnull() to detect them.
- What is method overriding?
- Method overriding occurs when a subclass provides a specific implementation of a method already defined in its superclass.
- How do you create a custom exception?
- Create a class that inherits from Exception: class MyError(Exception): pass
- What is the time complexity of binary search?
- O(log n) where n is the number of elements in the search space.
- How to merge DataFrames in Pandas?
- Use pd.merge() or DataFrame.merge() with parameters for join type (inner, outer, left, right).
- What is encapsulation in OOP?
- Encapsulation is the bundling of data and methods that operate on that data within a single unit (class), hiding internal details.
- How does garbage collection work in Python?
- Python uses reference counting and cyclic garbage collection to automatically manage memory and clean up unused objects.
- What is a lambda function?
- A lambda function is an anonymous function defined with the lambda keyword: lambda x: x**2
- How to create a NumPy array from a list?
- Use np.array(list_name) to convert a Python list to a NumPy array.
- What is the difference between loc and iloc in Pandas?
- loc uses label-based indexing, iloc uses integer-based (positional) indexing.
Fill in the Blanks
- ________ is the method called when an object is created. (__init__)
- ________ handles runtime errors in Python. (Exceptions)
- ________ sort is a simple sorting algorithm. (Bubble)
- ________ is a library for data manipulation. (Pandas)
- ________ is used for plotting in Python. (Matplotlib)
- ________ allows a class to inherit from another class. (Inheritance)
- ________ exception is raised for division by zero. (ZeroDivisionError)
- ________ search works on sorted arrays. (Binary)
- ________ provides N-dimensional arrays. (NumPy)
- ________ represents 2D data in Pandas. (DataFrame)
- ________ hides internal class details. (Encapsulation)
- ________ allows different classes to be treated uniformly. (Polymorphism)
- ________ handles exceptions in Python. (try-except)
- ________ complexity measures algorithm efficiency. (Time)
- ________ creates anonymous functions. (lambda)
- ________ joins DataFrames in Pandas. (merge)
- ________ provides vectorized operations. (NumPy)
- ________ is used for data visualization. (Matplotlib)
- ________ creates custom exceptions. (class)
- ________ searches sequentially through data. (Linear)
True/False
- Classes can inherit from multiple classes in Python. (True)
- Try block must have an except block. (False)
- Binary search works on unsorted lists. (False)
- NumPy arrays are faster than lists for numerical operations. (True)
- Pandas is built on top of NumPy. (True)
- __str__ method is called for string representation. (True)
- Exception handling prevents program crashes. (True)
- Bubble sort has O(n log n) time complexity. (False)
- DataFrame is a 2D data structure in Pandas. (True)
- Matplotlib is used for data visualization. (True)
- Private attributes start with double underscore. (True)
- Polymorphism requires inheritance. (True)
- Finally block executes only when no exception occurs. (False)
- Binary search is faster than linear search. (True)
- NumPy supports broadcasting. (True)
- Pandas Series is 2D. (False)
- Method overriding changes parent class behavior. (True)
- Custom exceptions inherit from BaseException. (False)
- Time complexity measures space usage. (False)
- Lambda functions can have multiple statements. (False)
Multiple Choice Questions
- What keyword defines a class?
a) def
b) class
c) object
d) init
Answer: b) class - Which exception is raised for division by zero?
a) ValueError
b) TypeError
c) ZeroDivisionError
d) IndexError
Answer: c) ZeroDivisionError - What does Pandas DataFrame represent?
a) A 1D array
b) A 2D table
c) A list
d) A dictionary
Answer: b) A 2D table - Which is the correct syntax for inheritance?
a) class Child(Parent):
b) class Child extends Parent:
c) class Child inherits Parent:
d) class Child from Parent:
Answer: a) class Child(Parent): - What is the time complexity of bubble sort?
a) O(n)
b) O(n log n)
c) O(n²)
d) O(1)
Answer: c) O(n²) - Which method is called when an object is created?
a) __new__
b) __init__
c) __create__
d) __start__
Answer: b) __init__ - What does 'self' refer to in a class?
a) The class itself
b) The current instance
c) The parent class
d) A global variable
Answer: b) The current instance - Which exception is raised for invalid dictionary key?
a) ValueError
b) TypeError
c) KeyError
d) IndexError
Answer: c) KeyError - What is the purpose of super()?
a) Create parent object
b) Call parent class methods
c) Access class variables
d) Create child object
Answer: b) Call parent class methods - Which library provides N-dimensional arrays?
a) Pandas
b) Matplotlib
c) NumPy
d) SciPy
Answer: c) NumPy - What does df.head() do in Pandas?
a) Shows last rows
b) Shows first 5 rows
c) Shows column names
d) Shows data types
Answer: b) Shows first 5 rows - Which sorting algorithm does Python use internally?
a) Bubble sort
b) Quick sort
c) TimSort
d) Merge sort
Answer: c) TimSort - What is encapsulation?
a) Hiding data
b) Combining data and methods
c) Both a and b
d) Creating objects
Answer: c) Both a and b - Which method removes missing values in Pandas?
a) drop()
b) remove()
c) dropna()
d) delete()
Answer: c) dropna() - What is the time complexity of binary search?
a) O(n)
b) O(log n)
c) O(n log n)
d) O(n²)
Answer: b) O(log n)
Application Based Questions
1. Implement a simple class for a Bank Account with deposit and withdraw methods.
Solution:
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def deposit(self, amount):
if amount > 0:
self.balance += amount
return f"Deposited ${amount}. New balance: ${self.balance}"
return "Invalid deposit amount"
def withdraw(self, amount):
if amount > 0 and amount <= self.balance:
self.balance -= amount
return f"Withdrew ${amount}. New balance: ${self.balance}"
return "Invalid withdrawal amount or insufficient funds"
def get_balance(self):
return f"Current balance: ${self.balance}"
# Usage
account = BankAccount(100)
print(account.deposit(50)) # Deposited $50. New balance: $150
print(account.withdraw(30)) # Withdrew $30. New balance: $120
print(account.get_balance()) # Current balance: $120
2. Create a class hierarchy for shapes with inheritance and polymorphism.
Solution:
class Shape:
def area(self):
pass
def perimeter(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def perimeter(self):
return 2 * 3.14159 * self.radius
# Polymorphism in action
shapes = [Rectangle(5, 3), Circle(4)]
for shape in shapes:
print(f"Area: {shape.area():.2f}, Perimeter: {shape.perimeter():.2f}")
3. Implement bubble sort algorithm with exception handling.
Solution:
def bubble_sort(arr):
if not isinstance(arr, list):
raise TypeError("Input must be a list")
n = len(arr)
for i in range(n):
for j in range(0, n-i-1):
try:
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
except TypeError:
raise TypeError("List elements must be comparable")
return arr
# Usage
try:
numbers = [64, 34, 25, 12, 22, 11, 90]
sorted_numbers = bubble_sort(numbers)
print("Sorted array:", sorted_numbers)
except Exception as e:
print(f"Error: {e}")
4. Create a data analysis program using NumPy and Pandas.
Solution:
import numpy as np
import pandas as pd
# Create sample data
data = {
'Name': ['Alice', 'Bob', 'Charlie', 'Diana'],
'Age': [25, 30, 35, 28],
'Score': [85, 92, 78, 96]
}
df = pd.DataFrame(data)
# Basic analysis
print("DataFrame:")
print(df)
print("\nSummary statistics:")
print(df.describe())
print(f"\nAverage age: {df['Age'].mean():.1f}")
print(f"Highest score: {df['Score'].max()}")
# NumPy operations
ages = np.array(df['Age'])
scores = np.array(df['Score'])
print(f"Age standard deviation: {np.std(ages):.2f}")
print(f"Score correlation with age: {np.corrcoef(ages, scores)[0,1]:.3f}")
5. Implement a custom exception and use it in a class.
Solution:
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Insufficient funds. Balance: ${balance}, Required: ${amount}")
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
return self.balance
# Usage
account = BankAccount(100)
try:
account.withdraw(150)
except InsufficientFundsError as e:
print(f"Transaction failed: {e}")
else:
print(f"Withdrawal successful. New balance: ${account.balance}")
6. Create a binary search function with proper error handling.
Solution:
def binary_search(arr, target):
if not isinstance(arr, list):
raise TypeError("Input must be a list")
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
try:
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
except TypeError:
raise TypeError("List elements must be comparable")
return -1 # Not found
# Usage
sorted_list = [1, 3, 5, 7, 9, 11, 13, 15]
target = 7
index = binary_search(sorted_list, target)
if index != -1:
print(f"Element {target} found at index {index}")
else:
print(f"Element {target} not found")
7. Create a Pandas program to analyze student grades.
Solution:
import pandas as pd
# Create student data
data = {
'Student': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
'Math': [85, 92, 78, 96, 88],
'Science': [90, 85, 92, 88, 95],
'English': [88, 90, 85, 92, 87]
}
df = pd.DataFrame(data)
# Add total and average columns
df['Total'] = df[['Math', 'Science', 'English']].sum(axis=1)
df['Average'] = df[['Math', 'Science', 'English']].mean(axis=1)
# Grade based on average
def assign_grade(avg):
if avg >= 90:
return 'A'
elif avg >= 80:
return 'B'
elif avg >= 70:
return 'C'
else:
return 'F'
df['Grade'] = df['Average'].apply(assign_grade)
print("Student Report:")
print(df)
print(f"\nClass Average: {df['Average'].mean():.2f}")
print(f"Highest Scorer: {df.loc[df['Total'].idxmax(), 'Student']}")
Examples
Object-Oriented Programming - Basic Class
class Car:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
self.mileage = 0
def drive(self, miles):
self.mileage += miles
return f"Drove {miles} miles. Total mileage: {self.mileage}"
def get_info(self):
return f"{self.year} {self.make} {self.model}, Mileage: {self.mileage}"
# Usage
my_car = Car("Toyota", "Camry", 2020)
print(my_car.get_info()) # 2020 Toyota Camry, Mileage: 0
print(my_car.drive(100)) # Drove 100 miles. Total mileage: 100
print(my_car.get_info()) # 2020 Toyota Camry, Mileage: 100
Inheritance and Polymorphism
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "Some sound"
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says Meow!"
# Polymorphism
animals = [Dog("Buddy"), Cat("Whiskers"), Animal("Unknown")]
for animal in animals:
print(animal.speak())
# Method overriding and super()
class Bird(Animal):
def __init__(self, name, can_fly=True):
super().__init__(name)
self.can_fly = can_fly
def speak(self):
return f"{self.name} says Chirp!"
bird = Bird("Tweety")
print(bird.speak()) # Tweety says Chirp!
Encapsulation and Special Methods
class BankAccount:
def __init__(self, balance=0):
self.__balance = balance # Private attribute
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def get_balance(self):
return self.__balance
def __str__(self):
return f"Account balance: ${self.__balance}"
def __add__(self, other):
if isinstance(other, BankAccount):
return BankAccount(self.__balance + other.__balance)
return BankAccount(self.__balance + other)
account1 = BankAccount(100)
account2 = BankAccount(200)
account1.deposit(50)
print(account1) # Account balance: $150
combined = account1 + account2
print(combined) # Account balance: $350
Exception Handling
def divide_numbers(a, b):
try:
result = a / b
return result
except ZeroDivisionError:
return "Cannot divide by zero"
except TypeError:
return "Invalid input types"
finally:
print("Division operation completed")
# Usage
print(divide_numbers(10, 2)) # 5.0
print(divide_numbers(10, 0)) # Cannot divide by zero
print(divide_numbers(10, "2")) # Invalid input types
# Custom exception
class NegativeNumberError(Exception):
pass
def sqrt_positive(num):
if num < 0:
raise NegativeNumberError("Cannot take square root of negative number")
return num ** 0.5
try:
print(sqrt_positive(-4))
except NegativeNumberError as e:
print(e)
Sorting Algorithms
# Bubble Sort
def bubble_sort(arr):
n = len(arr)
for i in range(n):
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
# Selection Sort
def selection_sort(arr):
for i in range(len(arr)):
min_idx = i
for j in range(i+1, len(arr)):
if arr[j] < arr[min_idx]:
min_idx = j
arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr
# Binary Search
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
# Usage
numbers = [64, 34, 25, 12, 22, 11, 90]
print("Original:", numbers)
print("Bubble sorted:", bubble_sort(numbers.copy()))
print("Selection sorted:", selection_sort(numbers.copy()))
sorted_numbers = sorted(numbers)
print("Binary search for 25:", binary_search(sorted_numbers, 25))
NumPy Arrays and Operations
import numpy as np
# Creating arrays
arr1 = np.array([1, 2, 3, 4, 5])
arr2 = np.array([[1, 2], [3, 4]])
zeros = np.zeros((3, 3))
ones = np.ones((2, 4))
range_arr = np.arange(0, 10, 2)
linspace_arr = np.linspace(0, 1, 5)
print("1D array:", arr1)
print("2D array:\n", arr2)
print("Zeros:\n", zeros)
# Array operations
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
print("Addition:", a + b)
print("Multiplication:", a * b)
print("Dot product:", np.dot(a, b))
# Mathematical functions
angles = np.array([0, np.pi/2, np.pi])
print("Sine values:", np.sin(angles))
print("Cosine values:", np.cos(angles))
# Statistical operations
data = np.random.randn(100)
print(f"Mean: {np.mean(data):.3f}")
print(f"Standard deviation: {np.std(data):.3f}")
print(f"Min: {np.min(data):.3f}, Max: {np.max(data):.3f}")
# Broadcasting
matrix = np.array([[1, 2, 3], [4, 5, 6]])
vector = np.array([1, 0, -1])
result = matrix + vector # Broadcasting
print("Broadcasting result:\n", result)
Pandas DataFrames
import pandas as pd
# Creating DataFrame
data = {
'Name': ['Alice', 'Bob', 'Charlie', 'Diana'],
'Age': [25, 30, 35, 28],
'City': ['NYC', 'LA', 'Chicago', 'Houston'],
'Salary': [50000, 60000, 55000, 65000]
}
df = pd.DataFrame(data)
print("DataFrame:")
print(df)
print("\nDataFrame info:")
print(df.info())
print("\nDescriptive statistics:")
print(df.describe())
# Selecting data
print("\nNames and Ages:")
print(df[['Name', 'Age']])
print("\nPeople from NYC:")
print(df[df['City'] == 'NYC'])
# Adding new column
df['Experience'] = [2, 5, 8, 3]
print("\nWith Experience column:")
print(df)
# Grouping and aggregation
print("\nAverage salary by city:")
print(df.groupby('City')['Salary'].mean())
# Handling missing data
df_with_nan = df.copy()
df_with_nan.loc[0, 'Salary'] = None
print("\nDataFrame with NaN:")
print(df_with_nan)
print("\nAfter dropping NaN:")
print(df_with_nan.dropna())
Data Visualization with Matplotlib
import matplotlib.pyplot as plt
import numpy as np
# Line plot
x = np.linspace(0, 10, 100)
y = np.sin(x)
plt.figure(figsize=(10, 6))
plt.plot(x, y, label='sin(x)', color='blue', linewidth=2)
plt.title('Sine Wave')
plt.xlabel('x')
plt.ylabel('sin(x)')
plt.grid(True)
plt.legend()
plt.show()
# Bar chart
categories = ['A', 'B', 'C', 'D']
values = [23, 45, 56, 78]
plt.figure(figsize=(8, 5))
plt.bar(categories, values, color=['red', 'green', 'blue', 'orange'])
plt.title('Sample Bar Chart')
plt.xlabel('Categories')
plt.ylabel('Values')
plt.show()
# Scatter plot
x = np.random.randn(100)
y = 2*x + np.random.randn(100)
plt.figure(figsize=(8, 6))
plt.scatter(x, y, alpha=0.6, color='purple')
plt.title('Scatter Plot')
plt.xlabel('X values')
plt.ylabel('Y values')
plt.grid(True)
plt.show()
# Histogram
data = np.random.normal(0, 1, 1000)
plt.figure(figsize=(8, 6))
plt.hist(data, bins=30, alpha=0.7, color='green', edgecolor='black')
plt.title('Normal Distribution Histogram')
plt.xlabel('Value')
plt.ylabel('Frequency')
plt.show()
Comprehensive OOP Example
class Employee:
def __init__(self, name, salary):
self.name = name
self.__salary = salary # Private attribute
def get_salary(self):
return self.__salary
def set_salary(self, salary):
if salary > 0:
self.__salary = salary
def __str__(self):
return f"Employee: {self.name}, Salary: ${self.__salary}"
class Manager(Employee):
def __init__(self, name, salary, department):
super().__init__(name, salary)
self.department = department
self.employees = []
def add_employee(self, employee):
if isinstance(employee, Employee):
self.employees.append(employee)
def get_team_size(self):
return len(self.employees)
def __str__(self):
return f"Manager: {self.name}, Department: {self.department}, Team Size: {self.get_team_size()}"
# Usage
emp1 = Employee("Alice", 50000)
emp2 = Employee("Bob", 45000)
manager = Manager("Charlie", 70000, "IT")
manager.add_employee(emp1)
manager.add_employee(emp2)
print(emp1)
print(manager)
print(f"Manager's salary: ${manager.get_salary()}")