Mid-Test Theory PDF
Document Details
Uploaded by Deleted User
Tags
Summary
This document provides a theoretical overview of object-oriented programming (OOP) concepts, including attributes, methods, encapsulation, abstraction, inheritance, composition, and polymorphism. It explains the different aspects of OOP and their applications in object-oriented programming, which are crucial for understanding programming paradigms.
Full Transcript
**[WHAT IS OOP?]** **Object-Oriented Programming (OOP)** is a programming paradigm that organizes code by defining classes, which are templates that group related data (attributes) and functionality (methods) into cohesive units. These classes act as blueprints for creating objects, which are indiv...
**[WHAT IS OOP?]** **Object-Oriented Programming (OOP)** is a programming paradigm that organizes code by defining classes, which are templates that group related data (attributes) and functionality (methods) into cohesive units. These classes act as blueprints for creating objects, which are individual instances that hold specific data and behaviours as defined by their class. It focuses on encapsulation, abstraction, inheritance, and polymorphism to create modular, reusable, and maintainable structures that model real-world entities and behaviours. **For instance**, if building software for a library, your objects might be books, members, and loans. Each of these objects has unique properties (e.g., a book has a title, author, and ISBN) and behaviours (e.g., a book can be borrowed or returned). So, unlike procedural programming, where the focus is on functions and tasks, OOP centres on entities (objects) that reflect real-world concepts. This makes it intuitive to think of the problem in terms of interacting objects rather than isolated operations. **WHAT ARE ATTRIBUTES & METHODS:** **Attributes** represent the state of an object, capturing data that defines the object\'s characteristics. For example, a \`Car\` object may have attributes like \`colour\`, \`model\`, and \`mileage\`. **Methods** define an object's behaviour the actions it can perform. For a \`Car\` object, these might include methods like \`drive()\` and \`stop()\`. **WHAT IS ENCAPSULATION?** **Encapsulation** bundles data (attributes) and functionality (methods) within a class. It is a way to restrict direct access to certain components of an object, hiding its internal data and functionality from outside interference. By controlling access, encapsulation ensures that data can only be modified in controlled ways through methods, preserving data integrity. Eg: \_\_balance is a private attribute within a class, and cannot be directly accessed from outside of class. And methods in class like deposit, withdraw control how this data (\_\_balance) can be accessed/modified. Ie: class BankAccount: def \_\_init\_\_(self, balance): def **deposit**(self, amount): def **withdraw**(self, amount): **WHAT IS ABSTRACTION?** **Abstraction** is the concept of hiding complex implementation details and showing only essential features of an object. It simplifies interactions by focusing on relevant attributes and behaviours. Eg: user only interacts with high-lvl methods like deposit or withdraw without worrying how transactions actually managed ie: without knowing about the internal method \_\_perform\_transaction that handles deposits and withdraws, but is hidden from user. Ie: class BankAccount: def \_\_init\_\_(self, balance): **EXPLAIN ENCAPSULATION VS ABSTRACTION:** **Encapsulation** is about **protecting** the object\'s data and controlling access to it using methods. **Abstraction** is about **hiding the complexity** and showing only the relevant parts of the object for interaction. **WHAT IS INHERITANCE?** **Inheritance** is a principle that allows a class to inherit attributes and methods from another class, promoting code reuse, creating hierarchies, and enabling subclasses to share and extend behaviours defined in their superclass. This makes the system adaptable. It establishes a "is a" relationship. Eg: Dog inherits from Animal, so Dog is an Animal and inherits Animal's charcateristics. Eg: class Animal: def breathe(self): class Bird(**Animal**): def fly(self): - Here, Bird **inherits** from Animal, meaning it automatically has the breathe() method. Bird is an Animal that can also fly. **WHAT IS COMPOSITION** *(not 1/4!)***?** **Composition** is a concept where one main class is built by combining objects of other classes. It enables a modular design by allowing classes to reuse functionality from other classes without inheritance. It establishes a "has a" relationship. Eg: Car class might have an Engine object, meaning a Car has an Engine. Eg: class Engine: def start(self): class Airplane: def \_\_init\_\_(self): - Here the **main class** is Airplane and it is build using only the **parts** of Engine it needs, so it does not use get\_fuel\_level() which is completely normal (unlike in inheritance). **EXPLAIN INHERITANCE VS COMPOSTION:** **Inheritance** is less flexible because subclasses inherit ALL characteristics and behaviours from superclass, which can lead to dependencies. **Composition** is more flexible since it allows classes to be constructed from independent parts, making it easier to modify. **WHAT IS POLYMORPHISM?** **Polymorphism** allows objects of diff classes to be treated as objects of a common superclass. It enables a single method to operate differently based on the object calling it. Ie: it involves defining multiple methods with the same name but diff parameters or implementations in a class. Let's say we have a superclass called Animal with a method speak(). Different subclasses of Animal, like Dog and Cat, can override the speak() method to provide their own implementation. Ie: class Animal: def speak(self): class Dog(**Animal**): def speak(self): class Cat(**Animal**): - Notice here polymorphism uses inheritance. - Simply put, polymorphism = overriding the behaviour of a base method (which exists in base class) **WHAT ARE MAGIC METHODS?** **Magic (dunder) methods** = special methods that allow to define how objects of ur classes interact with built-in operations and functions. They are automatically called by Python during certain operations and cab be customized for ur classes to modify or extend default behaviour. These methods are used implicitly by Python, but you can also call them explicitly when needed. When you use certain built-in functions or operators (like +, ==, len(), print(), etc.), Python automatically calls the relevant magic method behind the scenes. Examples: **\_\_str\_\_(self)** -- **String Representation** - Called by str() and print(). - **Use**: Returns a human-readable string representation of an object. This is what will be printed when you print the object. **Ie**: class Person: def **\_\_str\_\_(**self): return f\"I am {self.name}, {self.age} years old.\" p = Person(\"Alice\", 30) **print(p)** \# Output: I am Alice, 30 years old. **\_\_repr\_\_(self)** -- **Official String Representation** - Called by repr() - **Use**: Returns a more formal string representation of the object, often useful for debugging or logging. **Ie**: class Person: def **\_\_repr\_\_(**self): return f\"Person({self.name}, {self.age})\" p = Person(\"Alice\", 30) **print(repr(p))** \# Output: Person(Alice, 30) OR **print(p)** \# Output: Person(Alice, 30) Python automatically calls \_\_repr\_ ***\*\*\*If both \_\_str\_\_ and \_\_repr\_\_ are defined in a class, Python will use \_\_str\_\_ when you call print() or use str() on the object.*** ***\*\*\*If you didn\'t define \_\_str\_\_, but you did define \_\_repr\_\_, then print(p) would use \_\_repr\_\_*** ***\*\*\*if neither, print() prints object's mem address*** **\_\_eq\_\_(self, other)** -- **Equality Comparison** - Called by ==. - **Use**: Defines how two objects are compared for equality. **Ie**: class Person: def **\_\_eq\_\_(**self, other): return self.name == other.name and self.age == other.age p1 = Person(\"Alice\", 30) p2 = Person(\"Alice\", 30) **print(p1 == p2)** \# Output: True The **key take-home message** is that if you don\'t override magic methods like \_\_str\_\_, \_\_repr\_\_, or \_\_eq\_\_, Python will use default behaviors. For example, without overriding \_\_str\_\_, when you print an object, you\'ll see its memory address (e.g., \), which is not very informative. Overriding magic methods allows you to provide more meaningful representations and comparisons of your objects, **CLASS VS INSTANCE VARS** **Class Variables:** - **Definition:** Class variables are defined within a class, but outside any instance methods (i.e., they are not inside \_\_init\_\_ or any other method that belongs to an instance). - **Scope:** They are shared among all instances of a class. - **Purpose:** Class variables are typically used to store data that should be common to all instances of the class. For example, a counter for the number of instances created or a constant shared by all objects of the class. - **Access:** Class variables can be accessed using the class name or through an instance of the class. **Instance Variables:** - **Definition:** Instance variables are variables that are defined inside the \_\_init\_\_ method or any method that is specific to an instance. - **Scope:** They are unique to each instance of the class. Each object has its own copy of the instance variables. - **Purpose:** Instance variables are used to store data that is specific to each object or instance. For example, attributes like name, age, or id that differ from object to object. - **Access:** Instance variables are accessed using the self keyword inside the class, typically as self.variable\_name. class Person: \# Class variable species = \"Homo sapiens\" \# Shared among all instances def \_\_init\_\_(self, name, age): \# Instance variables self.name = name \# Unique to each instance self.age = age \# Unique to each instance def introduce(self): print(f\"Hello, my name is {self.name}, and I am {self.age} years old.\") \# Create instances of the Person class person1 = Person(\"Alice\", 30) person2 = Person(\"Bob\", 25) \# Accessing class variable print(person1.species) \# Output: Homo sapiens print(person2.species) \# Output: Homo sapiens \# Accessing instance variables print(person1.name) \# Output: Alice print(person2.name) \# Output: Bob \# Changing class variable Person.species = \"Homo sapiens sapiens\" print(person1.species) \# Output: Homo sapiens sapiens \# Changing instance variable person1.name = \"Alicia\" print(person1.name) \# Output: Alicia **CLASS METHODS** A **class method** is a method that is bound to the class, not an instance. It takes the class as its first argument, typically named cls, rather than an instance (self). Class methods can modify class state that applies across all instances of the class, or they can provide utility functions related to the class. **Decorated with \@classmethod**: The class method is defined with the \@classmethod decorator above it. **When to Use Class Methods:** - When you need to perform an action related to the class as a whole, rather than an individual object. - When you want to modify class-level variables, which are shared by all instances. - To create factory methods, i.e., methods that instantiate objects in different ways. class Car: \# Class variable wheels = 4 def \_\_init\_\_(self, color, model): \# Instance variables self.color = color self.model = model \# Class method \@classmethod def change\_wheels(cls, new\_wheel\_count): cls.wheels = new\_wheel\_count \# Modify the class variable \# Instance method def describe(self): print(f\"This is a {self.color} {self.model} with {self.wheels} wheels.\") \# Creating instances of the class car1 = Car(\"red\", \"Toyota\") car2 = Car(\"blue\", \"Honda\") \# Printing initial wheel count for both cars car1.describe() \# Output: This is a red Toyota with 4 wheels. car2.describe() \# Output: This is a blue Honda with 4 wheels. \# Using the class method to change the number of wheels Car.change\_wheels(6) \# After modifying the class variable through the class method, all instances reflect the change car1.describe() \# Output: This is a red Toyota with 6 wheels. car2.describe() \# Output: This is a blue Honda with 6 wheels. **Explanation:** - change\_wheels(cls, new\_wheel\_count) is a **class method** because it is marked with the \@classmethod decorator and it modifies the class-level variable wheels. - describe(self) is an **instance method** that describes an individual car object and its attributes (instance variables like color and model). - The class method change wheels modifies the wheels class variable, and this change is reflected across all instances of the class. **UNDERSCORE RULES** **\_var** = private/protected, intended for internal use BUT Not enforced by Python the variable or method can still be accessed from outside the class. class Example: def \_\_init\_\_(self): self.\_internal\_variable = 42 \# Intended to be private def \_internal\_method(self): \# Intended to be used internally print(\"This is an internal method.\") Even though \_internal\_variable and \_internal\_method have a single leading underscore, Python doesn\'t prevent you from accessing or modifying them. It\'s more of a \"don\'t use this unless necessary\" guideline. **\_\_var** = triggers name mangling in Python. When you use a double leading underscore in a class attribute, Python internally changes the name of the variable to prevent it from being overridden accidentally in subclasses. This makes the attribute harder to accidentally override from outside the class. - Name Mangling: The attribute gets \"\_ClassName\_\_var\" as its actual name. class Example: def \_\_init\_\_(self): self.\_\_private\_var = 42 \# Name mangling happens here example = Example() \# Accessing directly will cause an AttributeError \# print(example.\_\_private\_var) \# This will raise an AttributeError \# Accessing the mangled name works: print(example.\_Example\_\_private\_var) \# Output: 42 Intended for internal use: The name mangling protects the attribute from being accidentally overridden by subclasses or from external access, but it can still be accessed using the mangled name (though this is discouraged). **WHAT IS AN ALGORITHM** **Algorithm** = a set of finite steps/ instructions designed to perform a specific task or solve a particular problem. They are typically written in a way that is independent of any programming language and can be translated into code. For example: - **Sorting algorithm**: A procedure for organizing a list of numbers in ascending or descending order. - **Search algorithm**: A method for finding a particular item in a collection (e.g., linear search, binary search). **WHAT IS A DATA STRUCTURE** **Data structures** are specialized formats for organizing, storing, and managing data in a computer so that it can be accessed and modified efficiently. They define the way data is stored and the operations that can be performed on the data (e.g., searching, inserting, deleting, or updating). **LISTS** **Lists** are mutable ordered collections of items. They can hold any type of object (ints, strs, instances of classes, etc.) **Key methods:** append(), remove() -- remoevs first occurrence of value specified, pop() -- removes and return last item or item at index specified, extend() -- adds all elements from another list (or iterable, sort(), reverse() **TUPLES** **Tuples** are immutable ordered collections of items. They cannot be modified. **Key methods:** count(), index() Often used for fixed data that should not be changed. **SETS** **Sets** are unordered collections of unique items. They automatically eliminate duplicate items and do not maintain order. **Key methods**: add(), remove(), **DICTS** **Dicts** are unordered collections of key:value pairs. Each key is unique, values can be of any data type. **Key methods:** get() -- returns value for given key or None if key not found, items() -- returns view of dict's key-value pairs, keys(), values(), pop() **LIST COMPREHENSION** **List comprehension** is a concise way to create lists in Python. It\'s often more readable and efficient than using traditional for loops to populate a list. The basic syntax for list comprehension is: **new\_list = \[expression for item in iterable if condition\]** - expression: This is the value that will be added to the new list. It can be a modification of item or simply the item itself. - item: The variable representing each element in the iterable (e.g., list, range, string, etc.). - iterable: A collection (like a list, range, or string) that you are looping through. - condition (optional): A filter that only includes items that satisfy this condition. Eg: Let's say you want to create a list of squares of numbers from 0 to 9. - squares = \[x \*\* 2 for x in range(10)\] - print(squares) Now, let\'s say you only want the squares of even numbers. - even\_squares = \[x \*\* 2 for x in range(10) if x % 2 == 0\] - print(even\_squares) If you want to make a list of the first letter of each word in a sentence: - sentence = \"The quick brown fox\" - letters = \[word\[0\] for word in sentence.split()\] - print(letters) **LAMBDA ANONYMOUS FUNCTIONS (WITH NO NAME)** Syntax: **lambda arguments: expression** - expression is the code that the function executes and returns. [Examples:] a lambda function that adds two numbers: - add = lambda a, b: a + b - print(add(3, 5)) a lambda function to sort a list of tuples based on the second element - pairs = \[(1, 2), (3, 1), (5, 4), (2, 3)\] - sorted\_pairs = sorted(pairs, key=lambda x: x\[1\]) - print(sorted\_pairs) NOTE: sorted() is a function that returns a new sorted list (leaving original unchanged) and can be used on ANY ITERABLE while sort() is a method of list (modifies list in place ie: changes original), does not return new list, returns None map() is used to apply a function to every item in an iterable (like a list). Here\'s how you can use a lambda function inside map(): - numbers = \[1, 2, 3, 4, is5\] - squared\_numbers = list(map(lambda x: x \*\* 2, numbers)) - print(squared\_numbers) \# Output: \[1, 4, 9, 16, 25\] Let\'s say we want to filter out all **even** numbers from a list. - numbers = \[1, 2, 3, 4, 5, 6, 7, 8, 9, 10\] - even\_numbers = filter(lambda x: x % 2 == 0, numbers) \# Lambda checks if the number is even - even\_numbers\_list = list(even\_numbers) \# Convert the filter object to a list - print(even\_numbers\_list) \# Output: \[2, 4, 6, 8, 10\] **Advan of lambda functions:** 1. **Short functions**: Lambda functions are useful when you need a quick, one-liner function and don't want to write the full def function. 2. **Anonymous**: You can use lambda functions without having to name them.EG: a lambda function to sort a list of tuples based on the second element **HASH FUNCTIONS:** In Python, hash functions are commonly used to map data into a fixed-size value, which is useful in data structures like **dictionaries** and **sets**, where you need a quick way to compare, retrieve, and store data. **The main properties of hash functions are:** 1. **Deterministic**: Given the same input, it will always produce the same output. 2. **Fixed-size output**: The result (hash value or hash code) has a fixed length, regardless of the size of the input. 3. **Uniqueness**: Ideally, different inputs should produce different hash values (though collisions can happen). **Common Uses of Hash Functions in Python:** 1. **Dictionaries**: Keys in dictionaries are hashed to enable fast lookups. 2. **Sets**: Elements are hashed to determine if they are unique. 3. **Security**: Hash functions are used in cryptography (e.g., for password storage). When you use the hash() function in Python, it doesn\'t return a string representation of the object; it returns an integer hash value. Even though the inputs are a string (\"Alice\") and an integer (30), the hash function combines them in such a way that the result is a single integer. This integer is used internally to quickly compare and store objects in hash-based data structures, such as sets and dictionaries.