Python Programming: From Beginner to OOP Mastery | Olha Al | Skillshare
Search

Playback Speed


1.0x


  • 0.5x
  • 0.75x
  • 1x (Normal)
  • 1.25x
  • 1.5x
  • 1.75x
  • 2x

Python Programming: From Beginner to OOP Mastery

teacher avatar Olha Al

Watch this class and thousands more

Get unlimited access to every class
Taught by industry leaders & working professionals
Topics include illustration, design, photography, and more

Watch this class and thousands more

Get unlimited access to every class
Taught by industry leaders & working professionals
Topics include illustration, design, photography, and more

Lessons in This Class

    • 1.

      Introduction

      1:55

    • 2.

      Python Installation

      6:04

    • 3.

      Setting Up: Installing Code Editors

      3:54

    • 4.

      Managing Python Versions: Virtual Environments and Pyenv

      11:55

    • 5.

      Python Syntax and PEP Guidelines Explained

      6:41

    • 6.

      Python Numeric Data Types: int, float, and complex

      6:39

    • 7.

      Primitive and Reference Data Types in Python

      4:59

    • 8.

      Working with Strings in Python

      10:30

    • 9.

      Understanding the List Data Type in Python

      8:47

    • 10.

      Exploring the Tuple Data Type in Python

      9:09

    • 11.

      Understanding the Boolean Data Type in Python

      4:54

    • 12.

      Working with the Dictionary Data Type in Python

      7:22

    • 13.

      Exploring Set and Frozenset Data Types in Python

      8:15

    • 14.

      Working with Binary Sequence Types in Python

      14:08

    • 15.

      Working with Loops and Conditions in Python

      8:01

    • 16.

      Control Flow in Python: Break and Continue Statements

      7:17

    • 17.

      Working with Nested Loops and Conditional Statements in Python

      8:24

    • 18.

      Using Loops with Nested Data Structures in Python

      9:30

    • 19.

      Understanding List Comprehensions in Python

      13:55

    • 20.

      Using the input() Function in Python

      7:03

    • 21.

      Introduction to Functions: How to Create Functions in Python

      10:08

    • 22.

      Positional and Keyword Arguments in Python Functions Explained

      8:36

    • 23.

      Using Argument Packing and Unpacking in Python Functions

      8:34

    • 24.

      Understanding Lambda Functions in Python

      7:14

    • 25.

      Understanding Scope in Python

      8:24

    • 26.

      Handling Errors with try-except in Python Functions

      10:00

    • 27.

      Introduction to Modules in Python: How to Work with Them and Use the Import Statement

      16:48

    • 28.

      Using Decorators as a Design Pattern in Python: How to Implement and Apply Them

      6:19

    • 29.

      Building a QR Code Generator for Social Media with Python

      7:19

    • 30.

      Introduction to Object-Oriented Programming (OOP) in Python: Classes and Objects

      11:02

    • 31.

      OOP in Python: Working with Class, Static, and Instance Methods

      9:06

    • 32.

      OOP in Python: Single and Multiple Inheritance Explained

      10:07

    • 33.

      OOP in Python: Multilevel and Hierarchical Inheritance

      7:25

    • 34.

      OOP in Python: Composition

      11:46

    • 35.

      OOP in Python: Polymorphism

      5:10

    • 36.

      OOP in Python: Encapsulation

      13:32

    • 37.

      OOP in Python: Getters, Setters, and Properties

      6:33

    • 38.

      OOP in Python: Aggregation

      6:25

    • 39.

      OOP in Python: Abstraction

      10:39

  • --
  • Beginner level
  • Intermediate level
  • Advanced level
  • All levels

Community Generated

The level is determined by a majority opinion of students who have reviewed this class. The teacher's recommendation is shown until at least 5 student responses are collected.

5

Students

--

Project

About This Class

This Python programming course is designed for beginners and covers essential concepts like Object-Oriented Programming (OOP), functions, and data structures. In this course, we will create a QR code generator application for your social media profiles, work with Python modules, and dive into essential data structures. By the end, you'll have hands-on experience and a practical project to showcase your skills.
For this course, you'll find a huge collection of practical homework assignments to help reinforce the material.

Meet Your Teacher

Teacher Profile Image

Olha Al

Teacher
Level: Beginner

Class Ratings

Expectations Met?
    Exceeded!
  • 0%
  • Yes
  • 0%
  • Somewhat
  • 0%
  • Not really
  • 0%

Why Join Skillshare?

Take award-winning Skillshare Original Classes

Each class has short lessons, hands-on projects

Your membership supports Skillshare teachers

Learn From Anywhere

Take classes on the go with the Skillshare app. Stream or download to watch on the plane, the subway, or wherever you learn best.

Transcripts

1. Introduction: Hello, guys. Welcome to the ultimate Python programming course on skill share. In this comprehensive course, you'll dive deep into Python, mastering everything from the fundamentals to advanced object oriented programming. In this course, you not only learn Python from the very basics to advanced object oriented programming, but also discover how to work with virtual environments and manage multiple versions of Python. You will learn how to switch between versions and handle various libraries efficiently. These are crucial skills for working on real world projects where the ability to work with different Python versions and their respective libraries is often required. I believe in delivering valuable knowledge without the fluff. Each lesson is concise and packed with essential information, helping you learn effectively and with a focus on hands on experience. In this project, we will build a fully functional QR code generator application that can create QR codes for your social media profiles. You will learn how to use Python libraries, work with modules, and implement key data structures. Allow me to show you why diving into Python right now can be a game changer for your career and personal development. According to recent job market analysis, Python consistently ranks among the top programming languages in demand. This translates to numerous job opportunities and career stability for Python developers. Knowledge of Python can also open doors to specialized high paying roles such as data scientists, machine learning engineers, and AI specialists. Python is the best language for the beginners. It syntax is clear and straightforward, allowing you programmers to pick it up quickly and focus on learning programming concepts rather than getting bogged down by complex syntax rules. Investing your time in learning Python today is an investment in your future. Don't wait any longer. Enroll now and start your journey to becoming a skilled Python programmer. Let's unlock the power of coding together. 2. Python Installation: And first, let's check if Python is already installed on your system. If you have Windows, you can do this by pressing Wind plus R and typing CMD and hitting Enter. In the command prompt window, type the following command and press Enter. And here we are, we can see our Python. On MacOS and Linux, we also open terminal Window and type the following command and then press Enter. Don't pay attention all these Python versions. We'll cover it later. As we can see, I don't have the command Python version, but I have Command Python three version. The commands Python version and Python three version are used to check the version of the Python interpreter installed on your system. Python version, the command traditionally used to check the version of Python two, while Python three version is specifically used to check the version of the Python three interpreter. Typically, Python two was used on older systems. If you prefer, you can adjust your system configuration to make Python point to Python three. But I prefer continuing with the command Python three because it's commonly practiced. If you don't have any Python on your operating system, we will go to the python.org site and follow the instructions. Here you choose your operating system and choose version of Python you need. I work on MacOS and Linux, but installing Python on Windows is not much more difficult. You download the version of Python, then open download it file, and follow the installation prompts. Here we can download Python for Linux. If we go to Python, Python packaging user guide, and here I choose tutorials and then choose installing packages. We can see a lot of information how to check the version of Python and also how to install Python on different operating systems. And here you can notice that we can install Python in different ways. For example, here we can see the commands we can use for installing Python on a Bunto or if we use Macs, we can use Homebrew. Homebrew it's a package manager. You can read more on its site. With this package manager, we can install Python using this command. But what the difference? Well, shortly, the folder, Homebrew and official installer use the different folders for installation. Also updating. Homebrew easily update Python to the latest version with a single command Brew upgrade Python. Homebrew also manages dependencies and automatically cleans up old versions. While official installer to update, you need to manually download the new version from the website and go through the installation process again. The installation via the official installer is more self contained, which may be useful in certain scenarios, but can make managing dependencies and additional tools more cumbersome. And it often adds application icons in the applications folder. If I want to see all Python versions, I installed with the command Brew install Python, I can use the Command Brew List Python, and I immediately see all folders and all Python versions I have. Also, I want to notice that uni installation with Home Brew is straightforward with a single command Brew Uni Install Python, which completely removes the installed version from your system. While official installer uninstalling is more complex and require manual moving files from various directories. As for me, Homebrew is more convenient for developers and those who frequently work with Python and other tools via the terminal. Official installer might be suitable for users who prefer minimal system interference and need to install Python once for specific needs, and don't plan on frequently updating or managing multiple versions. The same thing if we use Linux, Ubuntu, whatever, we can use Sudo Ogat install Python or we can installing Python from source. In the first case, this is the simplest method to install Python, but you can only install versions of Python that are available in your distributions repositories. Installing from source, you can use any versions of Python, including the latest releases. Now that we've understood how to install Python and the difference between the installation methods, let's move on to learning Python itself. I open terminal and type the command Python. One of Python's unique features is its interactive mode, which lets you execute code and immediately see the results. This is made possible by the Python interpreter, a program that reads and executes Python code. When you install Python on your computer, it includes an interactive interpreter known as apple. Redevelop pre ent loop. It allows you to enter code one line at a time and see the result instantly. By using the interactive mode, you can quickly experiment with different code snippets and see the result immediately. To exit the interactive mode, use the command exit. Well, we saw interactive mode in the terminal, but we also have several tools that helps us to write code more efficiently. Let's take a look at them. 3. Setting Up: Installing Code Editors: Welcome back. In the previous lesson, we've discovered interactive mode when we can execute code and immediately see the results in the terminal. Today we will look at the tools that make coding easier and more efficient. Three of the most commonly used code editors are Pycharm, Visual Studio code, and Jupiter notebook. Which one you will choose, it's up to you. The first one Pycharm. There are several options community and professional. This idea is specifically designed for Python, but you also can use other languages. It's like a toolbox for Python developers. You can download Biharm Community. It's completely free, and to be honest, it's enough for the first time. The installation process doesn't take much time. We download the file and follow the instructions. There are a lot of features like debugging tools, project management, code suggestions. Very useful. It's better for larger projects where you need to keep everything organized and efficient. Then we have Visual Studio code. It's a lightweight code editor that supports many programming languages, including Python. After downloading the file, open it by double click it. This will extract the Visual Studio code application. Drag the Visual Studio code application into your application folder, and here we are, this installs VS code on your mac. Here you can create file, open folder, or view recently opened files here we can see several tabs. No folder opened. The tab appears when you haven't opened a folder for workspace. It prompts you to open a folder to start working on a project. Once you open a folder, the tab disappears and the files in the folder are displayed. Open Editor tab shows a list of all files you currently have opened in the editor. Here we can create new file from the scratch. The outline tab provides a structured view like functions, variables, classes in the currently open file. It helps you quickly navigate through your code by jumping to different parts of the file. If you want, you also can hide these tabs. It's perfect if you are working on different types of project and want a flexible and all in one editor. It's very easy to install and very easy to use. Then we have Judi notebook. It's a web based tool. You can use it for data science and research. It allows you to write and run code in small chunks, and you can immediately see the results. It looks like interactive mode, except this tool is great for creating interactive documents that combine code, text and visualization. Jupiter notebook, you can install different ways. Installing Jupiter using Anaconda and Conda or you have alternative. You can installing Jupiter with PIP package manager if you choose to use Anaconda, so it installs Python and Jupiter Notebook and other commonly used tag address for specific computing and data science. So you have all in one but to start, you can manage with Jazz Jupiter notebook by installing it using PIP. You can use whatever you want. It's up to you. In this course, I will use Visual Studio code. Well, after all of this preparation, let's start writing the code. See you in the next lesson. 4. Managing Python Versions: Virtual Environments and Pyenv: Very often in reality, you will have to work with multiple versions of Python. This is because each project has its own technology stack and package versions. To avoid creating a mess on your work computer and dealing with conflicts between different versions, it's ideal to use a virtual environment. It's not urgently needed right now, but I suggest you to understand how it works. It will help you a lot. You can skip this part. It will not affect your learning of the Python basics. This will be more necessary when you start working on a project. And now let's get started. Guys, if you want to manage multiple Python versions on your machine, there is a tool PMF. It lets you easily switch between multiple versions of Python and change the global Python versions on your machine. Let's get started from the Macos and then I will show you how it works on Ubuntu. The first step you need to take before installing anything new, it's update. And just in case upgrade to upgrade all packages. The first command updates local repository metadata, the second command, Brew upgrade, upgrades all the installed packages on your system to the latest available versions. It's commonly used practice that users first run Brew update to get latest metadata and then run Brew upgrade to update their installed packages. This ensures that the system has the latest software installed. We go to the Github and follow the instructions. Then we use them Brew install for installing PMF. Just copy this command and execute it. Let's return to the documentation and see what we need to next. Scrolling down, and here we are I use Z SH or shell. It's a common line shell that serves as an alternative to the more commonly known Boss shell. So I copy all this code and post it in SHC file. So we have installed PNF, and now I want to talk with you about Virtual environment. Virtual Environment solves a real problem. You don't have to worry about packages you install on the main system location. PyNVirtoNv is a plugin for the PNF tool that allows user to create and manage virtual environments for Python projects. With this, you can isolate project dependencies more efficiently. So again, follow the instructions and install this plugin. After installation, we copy this command and add this to the SHRC file. In this case, we do it manually. Open the HRC file. It's a hidden file and typically located in the user's home directory. I use simple user friendly text editor nano. You can use VIM. Here we can see three rows of code that was executed when PAN was installed. And pause this command here. I write my comment for the better understanding in the future. Again, for nano text editor, I use the commands Control O and control exit. It allows me write and exit from the text editor. You can use your text editor and execute your commands. Then restart shell with this command. And we can use these tools. So let's check it. Here we can see a small documentation with commands for PNP and PM Vertls. The first command, we check PN version. It displayed the currently active Python version, along with information on how it was set. For now, I don't have any. If I want to now list all Python versions known to PM I use the Command Pimp versions. And for now, I didn't install any Python version with PMP. If I want to see the list of Python version available to be installed, I can use the Command PM install dash list. So let's try to install Python with PM. For this, we use the command PM install, and then I specify the version of Python. I will install another version of Python to demonstrate how you can work in isolated virtual environments with different Python versions. Now at the same time, you will learn how to install and remove Python using PNP. If I now check the versions, we will see several Python versions. The Asterix indicates that right now I'm in a system global environment, but I have two Python versions that I can use for creating new virtual environments for other projects. On this operating system, I have globally, I mean, Python version 3.10 eight. I said globally because for every project, we can have its own Python versions. And now with this command, I will create the first virtual environment. For test project, I use the command Py Virtual ENF. Then I choose the version of Python, and then I can call my virtual environment, whatever it's up to you. I will call it NF and version of Python. And now with the command PN virtual lens, I can see list all existing virtual environments. To activate my newly created virtual environment, I use the command PNF, activate, and then name my virtual environment V 3.90. I immediately can see that I'm in it. If I check the Python version here, it's 3.90, unlike the global version that we tested earlier. If you have several virtual environments and want to switch between them, you can execute the command PM, activate, and then name other virtual environment, even if you write now in another active virtual environment. Now if we install something here, it remains isolated from global environment. Any packages or dependencies installed inside my virtual environment will not affect the system wide Python installation or other virtual environments that we can create. So let's install something here. Let it be Jupiter. I go to the documentation and follow the instructions. Jupiter it's a tool for code execution. I choose Jupiter, for example, it can be any packages or libraries you want. Now with the command PIP freeze, I can see all packages that was installed in my virtual environment. PP is a package manager for Python. Now let's imagine that we don't need this virtual environment anymore. How we can delete it. First, if it's active, we should deactivate it with the command PM deactivate. Then we use the command Virtual delete and name our virtual environment that we want to delete. So when I check Pi on Virtual lens, we don't see our virtual environment anymore. It was deleted along with all packages and libraries that we installed there very useful thing. But it doesn't mean that Python version that we use in this virtual environment was also deleted. If we check Python versions, we still can see several. I added before another one, just to show you how we can uninstall Python versions with BMP, the command, uninstall and then version of Python and Viola, we uninstall Python version 3.9 0.8. With these tool, it's so simple manage different Python versions. Now let's install it on a Bunto. We do the same thing. Go to Github page and follow the instruction. Here, I chose automatic installer. And here I copy this command for installation. Before installation, I use the command pudo Ug update, this command, managing system packages, then psudoUg upgrade. So we update and upgrade all installed packages on our system. Now we can install PAMP Fast this command that we copy previously. Then return to the front page, or documentation, and copy these three lines of code. We will write it in Bahar C file. I is also hidden file. It's very similar that we did with MACOS previously. If you couldn't install Tmp and you got an error, make sure that you installed all dependencies for Python and have Gid on your PC. After all of this, restart shell with the command, and we can use this tool. We use here the same command we used previously on Macros. Let's install Python 3.90. Here we can see our installed Python. Now let's create virtual environment based on this Python version. We activate it the same way as previous with MacOS with the command and activate and then name a virtual environment. Probably you won't encounter this problem, but I have inconvenient behavior on my system. Right now, I don't see that I'm in virtual environment. If I check it, I can see that we have created it. So I had to add these few rows of code in my BachRCFle and everything works. On a Bundu, I also use nanotext Editor. These commands allow me to write and execute from the BachRCFle. Execute the command source BRC. It's for execution the BRC script in the current shell session. And while we fixed it. Right now we are in our virtual environment, and inside, we have the Python version that was used for creation. So, guys, all commands and all the next steps, the same as we did previously. I hope this knowledge will help you see you in the next lesson. 5. Python Syntax and PEP Guidelines Explained: In the Python community, there is an established approach to making changes. No significant changes are made without the creation of a new document called the Python Enhancement proposal PEP. PAP documents play a crucial role in the Python community, serving different purposes depending on the topic. Information, they provide essential information for core Python developers and announce release scheduled for Python. Standardization. They establish coding style guidelines, offer documentation and provide other recommendations. Design, they describe proposed new features or functionality. A list of all proposed pbs is available in a dynamic, continuously updated document called Pbzero. Pap eight, the most well known PEP is the style guide for Python code outlining best practices for writing readable and consistent Python code. I also want to note that Python three is not backward compatible with Python two. Python two has efficiently been discontinued and is no longer supported. The Python community has fully transitioned to Python three, which is now the standard for all new development. In this course, we will be learning Python three. When learning Python, one of the first thing to understand is its syntax or the set of rules that define how Python code is written and interpreted. I can note that Python syntax is known for its simplicity and readability. And let's start from the first indentation to define blocks of code. Indentation and Python is a key part of the syntax and serves to define the structure and flow of the code. Like many other programming languages that use braces to denote blocks of code, Python relies on indentation to indicate which statements are part of the same block. We should know that indentation use four spaces per indentation level. Here we can see as an example function, the lines message and return message are indented under the deaf block, making them part of the grid function. The print statement is outside of the function and thus not part of the grid function. Mixing tabs and spaces for indentation within the same file can lead to indentation error. So we should use just one thing. However, in editors like VS code, there are mechanisms that may prevent this error from appearing immediately. Vis code often auto corrects the indentation as you type. It might replace tabs with spaces based on your settings, so you don't notice the issue unless you mix them explicitly. But you should understand that Python itself will still raise an indentation error if mixing occurs in the same block of code during execution. Let's go over a few new concepts variable, operation, operator, and expression and concatenation. In Python, a variable is an object implemented as a named memory location that can hold various values. A variable name starts with one or more Latin letters, and it can include digits and underscores. Also, variable names in Python are key sensitive, for example, name, name, and name are three distinct variables. Variables are assigned using the equals operator. It binds a variable name to an object. An operation is an action that needs to be performed on variables. It can be, for example, addition, division, subtraction, et cetera. An operator is a symbol or object that performs an operation and has familiar symbolic notation. You can see examples on the screen. An operand is an object, for example, a number, string, or variable on which the operator performs an operation. And we have expression is a combination of operations performed by operators on operans. Here we see example of expression where addition is the operation, plus in the operator and age and two are the operans. Congatenation is the operation of joining together characters or sets of characters. It can be done with plus operator. And also, it can be done with multiplication operator. As I said before, web eight provides guidelines on how to write Python code in a clean and readable way. And here we have some rules. Maximum line length limit all lines to a maximum of 79 characters. Blank lines and Python are used to make your code more readable and organized. They are not strictly required by the Python interpreter, but are helpful for people reading your code. It helps divide your code into logical parts like separating function definitions or separating chunks of code that do different things. So we have basic rules according to pap eight. Two blank lines between top level code like functions or class definitions, it makes it easier to see where one function or class ends and another begins. And one blank line inside functions or methods to separate logical sections of code. Commons allow you to explain what your code does, making it easier for others and yourself, of course, to understand the logic and purpose behind it. I pattern commands are not executed by the interpreter, so they don't affect the performance of your code. They are for human readers. We have single line commands and multiline commands. Single line commands start with the hash symbol. They are used to add brief nodes or explanation about a specific line or block of code. Multiline commands can be created using triple odes. It's for longer explanation to provide documentation for modules, classes, and functions. We can use either double quotes or single quotes. Both type of triple quotes serve the same purpose. The choice between them often comes down to personal or project specific style preference. Clear and consistent names, help others and yourself, understand the purpose of variables, functions, whatever may be classes. So we have name convention. Naming convention, use snake case for variables and functions and Camo Ks for classes. And now that we are familiar with the fundamental concepts and basic syntax of the language, let's begin exploring Python objects. 6. Python Numeric Data Types: int, float, and complex: In Python, everything is an object. Let's consider Python building data types. The first one numeric types. These are include integer that represents whole numbers. Flowed that represents decimal numbers, and complex represents complex numbers with real and imaginary parts. Let's take a look at an example to see how this works. In Python, integers are whole numbers that can be either positive, negative, or zero. They are one of the most basic and commonly used data types in Python. We can check data type exactly with type function. Here we have int. In many programming languages, integers are limited to a certain range depending on the size of the memory located. In Python, we have integers that can grow beyond those limits and are only constrained by the available memory, not by any fixed size. This means that Python can handle arbitrarily large integers without causing an overflow. We also can easy performing arithmetic operations with integers. Here we can use any arithmetic operator. The only limiting factor is your machines available memory, not Python itself. As we can see, this flexibility makes Python especially useful for mathematical operations involving very large numbers. It's very useful in applications where precision is critical. It's a common task in programming when you need to work with user input data and if it was represented as string, you can convert it into integers with It function. Now we can see that we have string. We use function type, and here we are, it's a string. But we need integer, so we use function Int. And if we now check the type, we can see that we have integer. Of course, we are not always can do this. When converting a string, really string to a number, you will usually see an error message. Let's continue with float. The flow data type in Python represents real numbers with a fractional part or decimal point. It's used to handle decimal numbers or numbers that require more precision than integers. Here we also have positive or negative floats. We also can notice that float numbers can be expressed using scientific notation, which is useful for representing very large or small numbers. We also can perform all basic arithmetic operations, multiplication, division, and so on. Floats are precise to about 15 to 16 decimal places. But beyond that, rounding errors can occur due to how floating point numbers are stored in memory. Floating point numbers are represented in a way that can sometimes lead to small precision errors. I show what I mean. This happens because floating point numbers are stored in binary form, and not all decimal fractions can be precisely represented in binary. As a result, we can see like this. When we work with financial labs, it can lead to serious errors, so how we can solve it. We have decimal module and pattern for this. It helps you to work with decimal numbers with arbitrary precision, avoiding these binary rounding errors. So if I really need precision, I should use decimal module. You may ask me why I'm using quotes here. Because when you create a decimal object from a float, Bthon first converts the float into approximate binary representation, which may introduce inaccuracies and using a string ensures that decimal interprets the number exactly as you wrote it without the intermediate step. This prevents surrounding errors or precision issue. In case we have one variable integer and another one is float. And if we divide an variable by float, the result will be of type of float in Python, when you perform arithmetic operations involving different numberc types. Python implicitly converts the types to ensure the operation can be performed with maximum precision. This is known as type coercion. The round function and pattern is used to round the floating point number to a specified number of decimal places. For example, we have a result like this, and I want to get rid of the extra digit after the decimal points. We use the function round and put here first number. We want to round and the second argument, and it's optional. We put the number of decimal places to which you want to round the number. If this parameter is omitted, the function rounds to the nearest integer. Let's go on with complex numbers, which consist of two parts, real part and an imaginary part. In Python, we have imaginary part is denoted using the suffix J. In other programming language, you may so I is used as imaginary part. So here we see example where three is real part. And four J is imaginary part. We have several building functions to work with complex numbers. And here we see example where I want to show first real part, and then output as imaginary part. On complex numbers, we also can use arithmetic operations like addition, subtraction and multiplication. Why and when we should use it. Well, complex numbers are essential for fields like physics, engineering, and signal processing. In the next lesson, we will consider string, list, double, and range. 7. Primitive and Reference Data Types in Python: In Python, all objects can be broadly categorized into reference objects and atomic objects. Reference objects don't directly hold the value, but instead reference or point to other objects that do. They include lists, dictionaries and custom objects related from classes. These objects can hold multiple other objects, which may be of different types. Atomic objects are the basic. Indivisible objects that hold a single value. These are the simplest types of objects in Python, such as integers, floats, strings, and booleans. They don't reference other objects and are the building blocks of them. When assigning atomic objects, their value is copied, whereas for reference objects, only the pointer to the object is copied. As a result, both variables after assigning refer to the same value. Let's take a look at an example to see how this works. For example, I created atomic object, integer, and here I assigned my object to other value, then I printed, then I change the value of A, let B six. And if we print both, we can see the difference. When you assign an atomic object like an integer, a copy of the value is made. Changes to the original variable don't affect the new variable. Let's consider reference object. I created reference object, list. Then I create Y variable, and it was assigned the reference to the list that we create earlier. I print all two variables. Then I modify list. I quickly explain in Python list are ordered collections that store multiple items in a single variable. The first element of the list is assigned using the index zero, the second element with the index one, and so on. I took the first one element and assigned it to ten. If I print both lists, we can see the same result. When we modify list through X, the change is reflected in Y as well because both X and Y point to the same list in memory. So when you assign a reference object like a list, both variables refer to the same object. Changes to the object through one variable affect the other variable as well, and you should be careful and always remember it. Reference objects can be mutable or immutable. Mutable object can be changed in place. Modifications don't require creating a new object. This means you can change, add or remove elements or values without creating a new object, and of course, changes to immutable object will be reflected across all references to that object. We saw this very clearly in the example of earlier when we used list and changed the first value, lists, dictionaries, sets, and user defined objects when attribute can be changed. These are all mutable objects. Immutable objects cannot be modified after they are created. If you want to change their content, it will result in the creation of a new object instead of modifying the existing one. We have integer, floyd, complex numbers, tapo, string, and bytes. As an example, I created immutable object like Taple Quick explanation. A tapo and Python is an ordered immutable collection of elements. We will explore all data types in more detail later in this course. Now I've taken the element from the Taple with Index zero, and this element is the number one, and I'm trying to replace the number one with the number ten. If I try modify this tuple, I will get an error. I can only create new one with different contents, but not always you've got an error. Sometimes it may seem like you have changed the object, but you didn't. For example, I create immutable object, string, and then I will make it uppercase letters at first glance, it's the same, but it's not. Upper method doesn't modify the original string A because string are immutable, as we remember in Python. Instead, it returns a string in upper case. We can check it with ID. We print original ID, and then we call upper method and then print ID after per method. So we can see that these two A variables totally different variables. The ID function returns the unique identifier, memory address of an object. The memory addresses of A in the first case and A in the second case are not equal. It confirms that they are not the same object, even though they have the same value 8. Working with Strings in Python: Welcome back. We continue with datatype, and now we will consider sequence type. And here we have string represents a sequence of characters. Last represents an ordered beautiful collection of items. Tapal represents an ordered immutable, as we remembered collection of items. We also have range as a sequence type in Python. It represents an immutable sequence of numbers and used for loops. So let's start from string data type. As I said before, string is a sequence of characters enclosed in quotes. It can be letters, numbers, symbols, and spaces. This is the most commonly used datatype in programming. The most common way to define string is by using single or double bodes. We already saw in previous lessons how we can do this. Both single and double quotes work the same way for most strings. Triple codes are usually used for multi line strings. And as we remember, string is immutable data type. This means that if you create a string, you cannot change its content. The only thing you can do it reassigning the variable to a new string, but the original string remains unchanged. And we clearly saw it in the previous lesson where we learn mutable and immutable data types. So each character in a string has a position called an index, which starts from zero. You can access individual characters using these index numbers. We can take the first element, let it be N. Let's take N or let's take K, but not only positive indexing, we also have negative indexing. We can take the last character using the index minus one or the second to last character, and we use index minus two and so on. We also can use slicing. We can extract a part of a string. We use the syntax where we have start is the index you start from and stop the index where the slice ends, but it's not included in the result. If you want to start from the beginning or go to the end, you can omit the start or stop indices. If you try to access an index in string that doesn't exist, Python will raise an index error. It indicates that the index is out of range. But if you are using slicing, Python is more forgiving. Let's say like this. It won't raise an error. Instead, it will return as much of the string as possible. Or an empty string if the slices is entirely out of range. So let's or a bit with these methods of this data type. We can change the case of the string with these two methods, lower and upper. The first converts all characters to lowercase and upper method converts all characters to uppercase. To remove white spaces from a string at the beginning and trailing space at the end, we can use strip method. We also can remove specific characters by passing them as an argument. For example, here, I want to remove exclamation mark. The replace method allows you to replace parts of a string with another string. This is very handy when you need to modify specific parts of string. For example, here, I replaced Java with Python. Let's imagine we need to find index of the first occurrence of the substring. And for this, we have two methods, find and index. They are both used to find the position of substring in a string. The first find returns the index of the first occurrence of the substring or minus one if the substring is not found. The index does the same but raises an error if the substring is not found. If we need to split a string into parts or join a list of strings back into a single string, we can use split or joined functions. Split function breaks a string into a list based on a delimiter. We can choose whatever we want. Default is space, but we also can specify a different delimiter. For example, here, I choose coma I use join method, it takes a list of strings and join them into a single string with a specified delimiter. We also can choose it. And here we got string instead of list. If we need to check, for example, file extensions or URLs or other common patterns, we can use starts with and end with methods. We check if a string starts or ends with a specific substrings. We have true if it really starts and falls if it's not true. And yes, we don't start our sentence from Java. And in this case, we check the string ends with a specific suffix. As you can see, an exact match is required. So you need to add an exclamation mark. If we need to know how many characters are using in a string, for example, we can use a an method. We can use it to validate user input. Let's go on with string formatting. We have three main methods to format strings. The old style using percent, the newer format method, and the most modern approach, Fstring introduced in Python 36. Let's start with the old style, which uses the percent operator. In this example, person S is a placeholder for a string, and person D is for an integer. We have two variable position and salary. One variable is a string, and the second one it's integer. So I want to create message where I will use these two variable. The result will be a formatted message like this. It's very useful. We can change the variable, and our message will be changed automatically. Let's proceed with new style formatting. Here we have the format method. It was introduced in Python three. This method is more flexible and readable than old style formatting. In this example, the Carl braces are placeholders for the values and the format method fills them in. It's more flexible and readable than using person symbols. Finally, we have F string, which were introduced in Python 3.6. This is the most modern and recommended way to format strings because it's cleaner, faster and easier to read. F strings allow you to directly embed expressions inside Carly braces. There is no need for any additional methods calls. Just use the F prefix, and you're good to go. This method is faster and often more readable, especially for complex expressions. When you use F string or format method, you can add formatting types inside the placeholders to control how the values are displayed. This is useful for specifying things like the number of decimal places, pudding, alignment, and so on. For example, here we have a left aligns, the name string within a field of ten characters. And here we have right aligns the score integer within a field of three characters. And of course, we can change it. We also can use F. It means format as a floating point number with two decimal places. And of course, you can change it. If we use it with comma, we have thousand separators. These formatting options help to make your output more readable and better. We also have escape characters in string. They are used to include special characters in strings that would otherwise be difficult to include directly. Using escape characters can help us add new line tab backslash. And we also can use single and double codes inside the string. So we got like this. We also can repeat a string a specified number of times using the Asterix operator. I will add a space to make it more readable. And of course, concatenation, we saw it in the previous lesson. See you in the next lesson. 9. Understanding the List Data Type in Python: So let's continue learn sequence types, and let's go on with list. List, it's a collection of items that can store multiple values in a single variable. As a data type, lists are ordered, mutable. Can be changed and allow duplicate values. They are defined using square brackets and can hold elements of any datatype like numbers, string, other lists, or even mixed types. The elements in the list have a defined order. After creation list, you can modify the elements inside, and as I said, in one value, you can store element of different data types. You can access elements of a list using their index, starting from zero. For example, here, I get element with Index two from List one and element with Index two from List two or let it be element with Index one. The first two lists we made with square brackets. You can directly assign the elements inside the brackets, separating them by comas. But we can also create lists like this. We can use the function list to create a new list, the function list and the argument string Hello. Converts the string hello into the list by treating each character in the string as a separate element of the list, and at the end, we will get hello consists of five characters. The function list takes each character and creates a list where each character is an individual element. We also can create list with function range. The range function generates a sequence of numbers, starting from zero up to but not including, let it be six in our case. Then by converting it into list using List function, we get a list of numbers 0-5 we also can generate a sequence of numbers starting from minus two and going up to by not including five. Again, a list function converts this range object into a list, and we have this result. By default, step is one, meaning it increments by one at each step. But we also can set it. For example, let's create another one list. Here I use the function range. That generates a sequence starting from -30 up to 50, incrementing by ten at each step. So we have number -30 is the starting point and the second number 50 is the end point, but 50 itself is not included. And the third number ten specifies the step size, meaning the sequence will increment by ten. In Python, when you use plus operator with two lists, it concatenates them, meaning it joins them together into a single list. We saw something similar when we learned string data type. When you multiply a list by an integer, in our case, let it be two. It repeats elements on the list two times in our case. The original list is not modified. Instead, U list is created with the separated elements, and of course, we can combine it with concatenation. If we want to add an element to the end of the list, we can use the method append. So in our case, the append method adds the value 33 at the end of the list. The append method modifies the original list directly. It doesn't return a U list. It updates the existing one. We want to delete the element with defined index in the list, we can use the statement Dell. It modifies the list in place by removing the item on the specified index. In our case, we delete an element with index three. In the case when we want to insert an element in a specific position or index, in a list, we can use insert function. First argument is the index where the new element should be inserted and the second argument, let it be happy in the element that will be inserted at that index. In case when we want to remove the first occurrence of specified value from list, we can use method remove so I decided to remove happy, so I put it as an argument, and here we are. Let's also remove 4.23. What's the difference between Dell and remove? Remove method is used to remove an element by its value. It searches for the first occurrence of the specified value and removes it from the list. If the value is not found, it raises a value error. The Dell statement is used to remove an element by its index or to delete a variable entirely. If we specify no index, it will delete the entire list. We also have pop method. It is to remove and return an element from a list. By default, it removes and returns the last element of the list, but you can also specify an index to remove and return a particular element. So in our case, we remove and return the last element in our list. This is 33. As you can see, I assign this 33 to a variable just to print it to be clearer. Then I'm going to delete the element with index two. In our case, it will be hello. If I print the list now, we clearly see that we remove hello from our list. Let me remove necessary thing, and let's print list six. We also can use here slicing technique. For example, here, I want to get slice from the second index till the four, but of course, four won't include in our result. It returns a new list containing the elements starting from the Index two and ending at the index three or let it be six. And we get the list, start from Index two and ends with Index five. In this example, slice extracts elements from the beginning to the list up to, but not including Index six. If the list has fewer than six elements, it returns the entire list. This example will show us that slice starts at Index two and includes all elements to the end of the list. We also have len function. In Python, it used to determine the number of items in an object as we can see, it returns eight. It means that the list contains eight elements. Let me print the list again, and I'm going to use reverse method. It used to reverse the elements of a list in place. This means that the order of the elements in the original list is reversed and the list is modified directly without creating a new list. So be careful. We also can sort the elements of a list in place using method sort. The method arranges the elements of the list in ascending order by default. This is not very good example. Let me replace it with at least one. And make some adjustments for clarity. So here we can clearly see that our list was sorted. But if we set reverse equals to true, the list is sorted in descending order. The default value of reverse, of course, falls, and we can omit it. In case when we want to remove all elements from our list, we can use the method clear. This method removes all elements from the list, resulting in a length of zero. This method doesn't return any value. It performs the clearing operation on the list itself. See you in the next lesson. 10. Exploring the Tuple Data Type in Python: Welcome back. Let's continue to learn Python data types. And now we will look at Taples. Taple is a collection of ordered immutable elements in Python. It's similar to List, but unlike List, it cannot be changed after creation. And here we use parenthesis. When we create a taple, the elements in a table maintain the order in which they were defined. And once created, the elements in a taple cannot be modified. Tapos can store different datatypes. For example, here, I create a Taple where we have integer and string. Also, the elements can be repeated. We can check that we have tapo with type function, but I want to notice that we also can create a Taple without parenthesis. I remove parenthesis to show you this is known as Taple packing. When you provide multiple values separated by commas, Python automatically groups them into a taple. So we have the same result as if we would use parenthesis. We can also create a taple with only one element when creating a taple with only one element, you have to indicate a comma after that element to ensure Python recognizes it as a taple. Without the comma, Python interprets it as a regular value inside parenthesis, not a tuple. If I remove the comma, you will see that the type was changed. And as we can see, we have just usual integer. But if I add comma, we have a table again. I will return everything as it was and remove unnecessary thing. The elements in a table can be accessed by using indices just like lists. Let's take, for example, the element with index two. And here we also have slicing. We learn it in the previous lesson. We have start the index where the slice starts inclusive, and the index where the slice ends exclusive. In the first example, I got slice. I extract all elements from Index two up to the end of the taple this slice extracts all elements from the beginning of the tuple up to Index two, but not including the index two. Here we have slice that extracts elements starting from Index one, up to Index four, but does not include the element at index four. It means start from Index one and stop at Index four. Here we have also negative indexes to access elements from the end of the tauple. As I said before, tuples are immutable, meaning that you cannot change at or remove elements after the tuple is created. For example, here, I try to change the element with index zero, and I've got an error. I create new tapo. I wanted to show you how we can concatenate two or more taples. We can combine the elements of the taples into a single taple using the plus operator, and this is concatenation, like we did with lists. After this, we have a new taple. The order of elements in the concatenated tapo is the same as they appear in the original taples. You can repeat the elements of a tapo using the multiplication operator. For example, here, I took tale B and use the multiplication operator and repeat it three times. In Python, you can check whether a specific element is present in a topple by using the in operator. This is called membership testing. The in operator returns a boolean value either true if the element exists in the tuple or false if it doesn't. So we can clearly see that two exists in Taple B, but if I choose 22, we got false. If we need to return the number of times a specified value appears in the tuple, we can use method count. So I tried to count the occurrences of value three in the topple B, and I got zero because I don't have three at all. But if I count the occurrences of the value two, I've got four, which is true. I will increase the topple a little just to show you another example. To find the index on the first occurrence of a specified value, we use the method index. For example, I wanted to know the index of five that we just added. So I got the index, it's four. But if I try to find out the index of elements that doesn't exist at all, we got an error. To avoid handling the value error expression, first, check the number of elements using the count method. If it's greater than zero, then calculate the position of elements using the index method. So I checked the number of elements. We got one, and now I can check the index because I'm sure that this number exists. For example, we can use ILs condition. We will take a closer look at this construction a bit later, but I will explain briefly the ILS construction is a control flow statement that allows your program to make decisions and execute certain blocks of code based on the conditions. The first condition we check if the count of value greater than zero, and if it's true, we print the index. The s block is optional, but it will be executed if the condition in the If statement is false. So I switch five to 15 to get false, and I will add s block. And inside, I print the message. So here we can clearly see that the condition returns false in the first block of code and the code inside the IV block is skipped. That's why we had the message from the second block of code. Also can check if the value five is present in the tapo using this construction. So if the value is found, which it is, the code returns true. So the first block of code returns true and we can get its index. But if I indicate the wrong number, we got the message. But guys, I will draw your attention to the fact that directly writing a number in the condition block is not good practice. The correct approach is to assign this number to a variable and then work with the variable. I'll remove this hard coded style and create a variable instead. Using a variable like item in our case, instead of hard coding, makes your code more flexible. As you can see, I can change the value of the variable without touching the actual AFLs code structure. Let's take a look at the unpacking. Taple unpacking allows you to assign elements of a taple to multiple variables. You can pack and unpack taples without needing extra syntax, which make the code cleaner and more concise. Here I unpack the taple into individual variables and print them. Our tapo is defined with five elements. It contains four integers and a string. I assign the values of the tapple to these bunch of variables, and this works because the number of variables on the left side matches the number of elements in the taple. But what happens if there are too few or too many variables if the number of variables on the left side doesn't match the number of elements in the double, Python will raise an error. Here we have a value error, too many values to unpack. This is because table unpacking requires the number of variables and elements to be exactly the same. If you want to unpack some elements, but not all, you can use the Asterix operator to capture the remaining values. This will assign the one to B variable. Hello, we assign to R variable, and the rest of the elements will be stored in E variable as a list. If I print the E variable, we will see this list. We can get the number of elements in the taple using length function. I create the variable B and assign the number of elements to this variable, and of course, then I print it. So we have five elements in the Taple See you in the next lesson. 11. Understanding the Boolean Data Type in Python: Welcome back. Et's look at Boolean data types. In Python, booleans are represented by the keywords true and false. True represents a logical yes or positive condition, and false represents a logical no or negative condition. I'll give you examples with expressions that evaluate to either true or false, so you can get the point. But first, let's consider boolean operators. Comparison operators used to compare values and return true or false. Here we can see that four equals to four, which is true. Then in this example, we can see that three not equals to four, which is also true, and then five more than one, and it's true. Then we have logical operators where we combine Boolean expressions. These operators are essential for controlling the flow of programs. We have three boolean operators, logical and logical or and logical note. The end operator returns true if both operands are true. If any of these operands is false, the result is false. We can see the table where we have two condition, and if two condition true, we got true result. So we have the same with false. If two conditions are false, we have false result. We already familiar with I block of code, so I will use it in this example. We have first condition if a more than zero and be more than zero. In our case, it's true because both numbers are positive. As a result, the whole expression evaluates to true, and we got the message, both numbers are positive. In case the first condition is false, Paton doesn't check the second condition because we'll get the false value anyway. Then we have logical or the or operator returns true if at least one of the operands is true. If both conditions are false, the result is false. So in this example, we have that all expressions evaluate to true because at least one number is positive. And here if the first condition is true, Biden does evaluate the second condition because we will get true anyway. And then we have logical not. The nod operator inverts the Boolean value of the operand. It returns true if the operand is false and false, the operand is true, the nod operator is often used to negate condition in A statements. For example, here we have A equals to ten, and we try to check if not A equals to zero, so it's true, A is not zero. We also can combine logical operators. You can combine multiple logical operators to form complex conditions. We use parentheses to group conditions and ensure the correct order of evaluation. So in the first case, we have true because both A and B are positive. In the second condition, if we check whether the C not equals to 15, we got false. Because actually C equals to 15. Since the or operator is used, the entire expression values to true. Let's check it. Here we are we got true because as we can see from the table, true or false is true. Let's consider order of precedents for logical operators. Here you can see from highest to lowest. In Python, when multiple logical operators are used together in an expression, the order of precedents determines which operators are evaluated first. This is essential because it affects how the overall expression is interpreted and its final outcome. Knot has the highest precedents among logical operators. This means that when you use not in an expression, Python will evaluate the not operator before anything else. Then we have second highest precedence, operator and has the second highest precedence. This means that in an expression with both, for example, and or operators, but then evaluates the expression first, and the operator or has the lowest precedences. Here we have example. Naught is evaluated first. B and C is evaluated next. Here false and true becomes false. And finally, false or false is evaluated resulting in false. See you in the next lesson. 12. Working with the Dictionary Data Type in Python: Welcome back. Let's go on with dictionary. Dictionary and Python is an ordered mutable collection of items. Each item is a key value pair where keys must be unique and immutable, and values can be any data type. We can create a dictionary by enclosing the key value pairs in CRL braces. Each pair is separated by a comma, and the key and value within each pair are separated by column. Here we have keys, name age, and city and the values Alice, 30, and New York. Also, we can create a dictionary using the Dict function. This is especially useful when you want to create a dictionary with more complex data types or when the key are not strings. Here we have a dictionary using the dict constructor. You can start with an empty dictionary and add Key value Pars later. For example, here, I created empty dictionary, and then I'm adding Key value Pars one by one. And if I print the result, we can see that we filled this dictionary. The most straightforward way to access a value in a dictionary is by using its key within square brackets. In this example, I retrieve the value associated with the key name. But we also have G method. It allows you to access dictionary values, providing a way to handle situation where a key might not exist. This method returns known if the key is not found, avoiding a key error. If I use for these keys, square brackets, we got the error. If you want to retrieve all keys or values in a dictionary, you can use the keys and values methods. Here we can see that the keys method returns all keys and the method values returns view object displaying a list of all values. As I said before, dictionary in Python is immutable. This means you can change the contents of a dictionary after its creation. You can add remove or update values, how to add new values we saw before when I filled totally empty dictionary. Now I want to show you how to add multiple key value pers. For this, I will use the update method. This method takes another dictionary or iterable of key value pers, and we update our dictionary with this new data, and here we can see you can also update the value associated with an existing key by simply assigning a new value to that key. For example, here, I updating an existing value age, and I change it to 31. Now let's go on with deletion. The Dell statement allows you to delete a specific key value purse from a dictionary by referencing the key. For example, here, I remove a key value pair age. So we have the updated dictionary. If I try to delete a key that doesn't exist, I got an error. We also have the pop method. It removes the key value pair by key and returns the value associated with the key. So here I use the pop method. I specified the key city. I printed the value that I removed. And then our dictionary. And here we can see that I removed Chicago. If the key doesn't exist, you can specify default value to return instead of raising an error. If I try to pop a key that doesn't exist, I can avoid an error by providing default value. For example, here I provide the default value, not found. I don't have such keys, so I've got not found output. The pop item method removes and return the last inserted Kevalu pair from the dictionary. This method is particularly useful when you need to clear the dictionary incrementally. Here I add the new item. I call it last item, and then I will remove it with pop item method. I created the new variable, last item, and assigned my dictionary where I removed the last item with pop item method. Then I print removed item and my dictionary after this. So we clearly see that we removed the last added item. If you want to remove all items from dictionary, you can use the clear method. This method empties the dictionary but keeps it in memory, and you can use it for further work. After calling clear method, the dictionary will be empty, but it still exists and we can use it. The items method in Python is used to return a view object that displays a list of dictionaries Key value pairs as tuples. If you need to store or manipulate the key value pairs outside the dictionary, you can use the list function. If I check that type, here I've got dict items. But after list function, I got list. It converts our dictionary into a list of key value pairs. I print that I've got, and we see list. The items in a dictionary don't have a defined order in Python 3.7 and later dictionaries maintain the insertion order, but they are still considered unordered collections. Each key in dictionary must be unique. If you use a key that already exists, the new value will override the previous value. We can check the presence of key in a dictionary with operator. This returns true if the key exists in the dictionary and false otherwise. In the first example, we have true and we print the key exists, and in the second example, we have the false we also can check the value in a dictionary, and we do the same way. But with the values method, it can help you to avoid overwriting existing values in a dictionary before adding a key value pair. So you can check whether the key or value already exists. If you need to sort the dictionary by its keys, you can use the sorted function. It returns a list of sorted keys, which you can then use to reorder the dictionary. See you in the next lesson. 13. Exploring Set and Frozenset Data Types in Python: Back. We often have the situations where we need to handle groups of unique elements efficiently. And this is when sets and frozen sets come in handy. Set and Python is built in data type that allows you to store a collection of unordered and unique elements. It's very useful for tasks such as eliminating duplicates from data or performing mathematical operations like union and intersection. It's mutable elements can be added or removed, and we also can create it by several ways. The first case, we create it with Carla braces. And the second, we create empty set with the set constructor, if I want to add a single element to a set, I can use add function. For example, let's fill this empty set. The elements and set don't have the defined position or index. This means that you cannot access items like we did with list or tuples. And as I said before, set has unique elements. Set automatically eliminates duplicate entries. If you try to add an element that is already present in the set, it will simply be ignored. I add another name to make our set a little bit bigger. I wanted to show you how we can remove specific element and we can use it with the function remove. Here we can see that I removed the name Nick. If the element is not found, it raises an error. But if we want to avoid the key error, we can use the function discard. And here we can see if I remove an existed object, we got nothing. But if I remove name Nik, we have the same result as we had with remove function. We also can use the method clear to remove all elements from the set. Let's consider such mathematical operations like union, intersection and difference. For this, I created two sets, and in the first case, we combine elements from two sets, excluding duplicates. As we can see, three was duplicated, but here we don't see it. We have totally unique elements. Okay, let's consider another example, intersection. I retrieve element common to both sets, and here we have three. Let's get elements present in one set, but not in another. Here I use the minus. And we got one and two, which is true because we have one and two in first set, but we don't have it in the second. And let's consider symmetric difference. I retrieve elements that are in either of the sets but not in both. So here we have unique elements, but as we can see without three, because the three were present in both sets. You can also check if an element exists in a set using the in operator. For example, here I have set and in block of code with If statement, I check if the hello string exists in my set. And if it's true, we got this message. Let's go. And here we have a frozen set. It's immutable version of set. But despite this immutability, frozen sets still support many of the same operations as sets. We can create frozen sets using the frozen set constructor, and I can pass any terable objects like staple or another set. For example, let's create frozen set from list. Here I have simple list, and then I create with the constructor frozen set and pass this variable, my list. And here I printed our frozen set that I've got from the list. I can also create new frozen set from set. So first, I create simple set, and then I pause it in frozen set. I also can create frozen set from string. Since string is iterable, you can create it easily. As I said before, frozen set is immutable, so we cannot apply the methods like add remove or clear. Like sets, frozen sets are unordered. We don't have specific order of elements, and I can't take specific elements from here like we did with list or double, but elements are unique, so duplicates are automatically eliminated when creating a frozen set. As you can see, here we have two, but when at my frozen set, I have just one L. But we can also check if an element is present in a frozen set using the in or not in operators like we did with set. For example, here I checked if H letter was present in my frozen set and we got true. I quickly show you frozen set operations and I start from Union. I have two frozen sets and after Union, I have frozen set that contains all unique elements from both frozen sets. Then I use intersection. For this, I add elements to both frozen sets. The intersection of two frozen sets contains only the elements that are present in both frozen sets. The difference variation returns the elements that are present in the first frozen set, but not in the second. With a symmetric difference, we have the result where we have elements that are in either one set or the other, but not in both we have unique elements one and two from the first frozen set and the unique elements five, six from the second frozen set. Then I'm going to check if all elements on the first frozen set are contained in the second frozen set and we have false. I slightly change my first frozen set just a bit. Now I'm going to check if frozen set two contains all elements from the frozen set one. And eventually we have Dre. If I have to check that two frozen sets have no elements in common. I can use the ISDs joint method. If I check right now, we got false because it's obvious that we have common elements. I slightly change my frozen sets. And in this case, we got true. So we considered two data types set and frozen set, set are mutable collection of unique elements, frozen set, and immutable collection of unique elements. If we are talking about performance, set slightly slower for hashing because it's mutable, frozen set a little bit faster due to immutability. See you in the next lesson. 14. Working with Binary Sequence Types in Python: Welcome back. Guys, you are awesome. You've done so amazing job so far. Keep up the great work. The most exciting challenges are still ahead. Let's keep going. Today we will cover the last of the built in data types and then move on to loops and conditional statements. Well, binary sequence types. These are specialized data structures designed for handling binary data. They are essential for working with files, interacting with hardware or performing network communications, and they start with bytes. The Bytes type and Python represents an immutable sequence of bytes, with each byte being an integer value 0-255. A bytes object is similar to a string, but is used for binary data than text. And as I said, it's immutable. So once you create it, its content cannot be changed. There are several ways to create bytes object. The first way using the B, single quotes or double quote notation. This syntax is the most common for creating bytes object, especially when working with text that has been encoded. The second way, we can create the Bytes object using the Bytes constructor, and I pass here the string hello, but I also have to specify the argument encoding UTF eight. The bytes function can create a bytes object from various types of data. But when we use it with a string, we have to specify encoding so Python knows how to convert each character into binary. And UTF eight encoding is one of the most common ways to encode characters. I'll show you how it works. If the string contains special characters, UTF eight would encode the symbol as multiple bytes. In my case, it's represented by two bytes, and other characters follow in a similar manner. Computers store data in binary format, so any text or characters needs to be encoded into bytes to be stored or processed in memory, and the UTFAight is a versatile encoding standard compatible with ASCE. What is aski? This is American Standard code for information interchange. We have a character encoding standard that was originally developed to represent English characters in computers and other devices. So if the string contains only ASCI characters, as I said, English letters, numbers, the bite representation will look very similar to the original text with B. Prefix to indicate that it's now bytes. But when the string contains characters outside the standard ASCI range, those characters will be represented by their corresponding UTF eight byte sequences. And here we can clearly see it. So in this way, the code provides a way to bridge the gap between human readable text and machine readable binary data. ASCI plays an important role in email communication. Email protocols use ASCI text to communicate between email servers. This also the basis for many file formats. Many programming languages also use ASCI for source code files. It's often used to manipulate strings and programming, particularly for basic string processing tasks. ASCI Values make it easier to compare, sort and perform arithmetic on characters, and it's also widely used in embedded systems for communication and control. I hope I made it clear for you. We also can create bits object using integer. When you pass a single integer to the Bytes constructor, it creates a Bytes object filled with null bytes and the specified length. In this example, five produces a sequence of five null bytes. It looks like this. This is often used in low level programming. If we need binary representation of list, we also can make it. When I call this constructor, Python processes each integer in the list and we got the integer one becomes the byte like this, the integer two becomes the byte like this, and so on. Eventually, we have the final object, that is a sequence of bytes. That is immutable, and it means that you cannot change individual bytes in this object. Despite the fact that we had a list that could have been changed initially. ASCI characters are numbered 0-127, and each character corresponds to a unique number in this range. We can see it here on the side in the table, the first 31 characters and the 127th character unprintable. These are control characters. They are not represent written symbols, but we have printable characters, and these represent letters, numbers, punctuation marks, and symbols that you can see and use in text. Let me show you. When you call Bytes Constructor and pass here list like this, Python takes these numbers and creates a Bytes object that represents a sequence of bytes. If the values in the list are in the range 0-127, Bython interprets them as ASCI codes, and as a result for numbers in the range 32-126, as I said before, these numbers correspond to printable ASCI characters. We will see on the screen that Python shows them as characters. And then if I print the result, you can see the string representation hello. We converted the integers into their corresponding characters according to the ASCI standard. Numbers outside the range of ASCI table will be represented as hexadecimal codes. We can check it, and we can clearly see that 72 corresponds to H, 101 corresponds to E and so on. Understanding and working with Bytes and Python is crucial for various applications programming and data manipulation. Who knows where you will use Python? It can be useful for file handling or cryptography and security, or you can use it for image and audio processing or network programming. So let's consider some methods for Bytes. To convert Bytes back into the original list of numbers, you can use the list function on the Bytes object. This function will return a list of the integers that were used to create bites in the first place. We can obtain a slice from a Bytes object in Python. For example, here we have Bytes object from the string hello. For example, I want to take the bite at index one. When you access a specific byte by its index, Python returns the corresponding integer value of the byte, which is its ASCI code, and in our case 101 corresponds to E. But when I got slice of the Bytes object, as a result, I have another Bytes object, which is displayed with the B prefix. Slices of Bytes objects are widely used in programming for various purposes. For example, we may need to extract specific sections of the data for analysis. Or, for example, if we are processing image files, we may want to extract the header or specific bite sequences that represents certain properties of the image. Bytes support most string methods. However, some string functions don't work correctly with the bite objects. For example, the LN function returns the number of bytes that strings occupies in memory, not the number of characters as we could see when we worked with string. For example, here I have Bytes object, that will be Hello which consists of five Ask characters. Each character in hello is represented by a single byte in UTF eight. So I got five. But since the characters are non asci, they will take more than one bytes each. In UTF eight, each of the character in our new data is represented by multiple bytes. So we got the total length of the bite representation, 12 bytes. If we need to transform it back into human readable strings, we can use decode method, and of course, specifying UTF eight, again, indicates that we want to convert the bytes back to a string using the Sam encoding. So let's continue with Bt array. It's a built in data type that represents immutable sequence of bytes. Each element in bitary is an integer ranging 0-255 representing a byte of data. Unlike the bites type, which is immutable, bitary allows you to modify individual bytes and adjust the length of the bite sequence after it's created. Let's create an empty bitary object with no elements, and we can do this with such constructor. If I print it, we can see empty Bary we also can create Bary from string. In this example, I created a bite array by encoding a string in a specified encoding format, UTF eight. A Bary can be created from various sources and it's mutable. Here I'm creating a bitar array from a list of integers where each integer represents a byte value in the range of 0-255, as I said before, each number corresponds to the ASCI code point of character. We can create Bt array from an existing Bytes object. Since Bt array is mutable and Bytes is immutable, this allows you to create a modifiable version of Bytes object. This approach is very useful when you receive data from the form of bytes, but you need to modify its content. Speaking about modifying content, you can access or modify individual bytes by their index, just like with lists. You can also extract a range of bytes using slicing syntax. So we are dealing with mutable data type, so we can add a single byte to the end of the byte array. And for this, I use the method append. And of course, byte to append should be an integer in range 0-255. In my case, 33 is the ASCI code for exclamation mark. In case to add multiple bytes at once, we can use Extend method. And here we added world. We can concatenate bitary with another bitary or bytes using the plus operator. It's very similar like we did with string. Let's continue with deleting and popping. You can remove bytes by index using the dell keod. This deletes the bite at the specific position. And in my case, I delete the byte with Index two or let it be the index one. To remove and turn the last byte or bite at specific position, I use pop method and we already know that 111 corresponds to O in ASCI table. We can also specify an index. In this case, we remove Android turn and the bite with index 101. To get the index of the first occurrence of a byte sequence within a byte array, I can use the find method. Here we got starting index of the world and date six. If you need to convert the Bt array back to an immutable bytes object, we can use a Bytes constructor. For example, here I have Bt array, but then I decided to make it an immutable data. So I use the constructor bytes. And here we are when you've been working with bytes, but then you want to display or further process the data as a text, you can decode it and you use for this decode method. This converts the bitary back into a string. Here's the table that outlined the key differences between bytes and Bt array in Python. We use bytes when you want to work with data that should not change. And we use Byte array when we need a mutable sequence of bites, that can be updated or modified. For now, that's all. See you in the next lesson. 15. Working with Loops and Conditions in Python: We've already encountered with this operator before. Now let's take a closer look at it. The I operator in Python selects actions to be executed during the program's operation depending on conditions. If the result of the first condition check is true, then the first block of code is executed. Otherwise, the second condition is checked, and if it returns true, then block two is executed, and so on until a condition that returns true is found. Or the else condition is reached. If else is absent and all condition checks return false, then none of the branching blocks will be executed. The code block following the If statement is mandatory. If it's necessary to indicate that nothing should be executed in that block, the pass statement is used. This is placed where the command should be, but it performs no actions. In Python, you can use a shorthand for the statement called the ternary conditional operator or conditional expression to write an eval statement in a single line. This shorthand is particularly useful for simple conditional assignments or operations. Okay, let's try. Imagine we want to check if a number is even or odd and assign a result based on that condition. The expression is used to check if a number is even. The modulus operator returns the remainder of a division. If the remainder of a division equals to zero, so we got true, then the number is even, or if it's false, then the number is odd. Using the ternary operator, it looks like this. We have expression at the condition, and if the condition is true, we have even, and it's assigned to result. These two expressions are absolutely equivalent and produce the same result. But if the condition is false, we got odd. Let's look at for loop and Python. This is a control flow statement used to iterate ovary sequence like a Les Apple dictionary or string or even range. It executes a block of code for each item in the sequence. It's commonly used when you need to perform an action a specific number of times and want to process each item in a collection. The four loop and python takes each item from a sequence one by one and assigns it to loop variable. The indented block of code following the column is executed once for each item in the sequence. So let's start with a simple example of four loop. I created list, called it numbers. Then I created four loop, and I iterated through numbers and print each number. So the loop iterates over the list. For each item in the list, the loop variable number takes on its value and then print statement, output the value of number in each iteration. We can do the same thing, but I will use range. Range function is often used with four loops to generate a sequence of numbers. It's useful when you want to loop a specific number of times. This function has three parameters, start, stop, and step, but we can pass only one argument. In my example, it will be five. So the range generates number from zero up to but not including five. The loop variable I takes on each value 0-4 and then we'll print it. You can also specify start value and step value. And in this example, function range generates numbers starting at two, ending before ten and incrementing by two. You can use four loop to iterate over characters in a string, which is helpful for task that requires string manipulation or analysis. In this case, each character of the string Python is assigned to the loop variable letter in each iteration, and then I printed one by one, each character. As I said before, the four loop can iterate over any iterable object, including lists, tables, dictionaries, and sets. Here we can see example with lists. The same we can have with taples. The four loop is a powerful tool for iterating over sequence. It's commonly used with the range function to loop a specified number of times. With a four loop, you can iterate over lists, strings, tuples, and other iterables, making it a versatile choice for many programming tasks. We also have wild loop and Python. It's also control flow statement that allows code to be executed repeatedly based on a given condition. The loop continues to run as long as the specified condition is true. It's particularly useful when the number of iterations isn't known ahead of time and you want to continue looping until a certain condition changes. The wild loop checks the condition before each iteration. If the condition is true, the code inside the loop block is executed. Once the code has been executed, the condition is checked again, and if it's still true, the code block runs again. This process repeats until the condition evaluates to false, at which point the loop terminates and the program moves to the next section of code. So here we have condition. This is logical expression that returns true or false. When true, the loops continues, when false, the loop stops. And then we have a loop body, the indented block of code that runs repeatedly as long as the condition is true. In this example, the loop starts with count one. Then the condition count less or equal five is checked. Since count is initially one, the condition is true and the loop executes, and we print count is one. Then it incremented by one. The loop then checks the condition again and this repeats until count is greater than five. We can create infinite loop. It will continue indefinitely if the condition never becomes false. To stop this loop, we can press Control plus C or common plus C. Depends on your operation system and settings. In the next lesson, we will cover the break and continuous statements for controlling the flow of loops. 16. Control Flow in Python: Break and Continue Statements: Welcome back, guys. Now we're going to learn break and continue statements in Python. These are used to control for and while loops. Each statement has a specific purpose. Break immediately terminates the loop it's in and the program continues with the next statement after the loop. The break statement is used when you need to exit a loop before it naturally finishes. This is helpful when you encounter a specific condition and no longer need to continue looping. So imagine we have a list of numbers and we want to stop looping as soon as we encounter the number seven. For this, I use four loop, and inside, I write condition. If number equals seven, we bring the message. And then I use the break operator. But until we meet the condition, we will print the numbers. If I run this code, we will get like this. The loop iterates over each number in the number list, and when number equals to seven, the if condition is true. So the break statements executes. The loop stops immediately, so number eight, nine, are never printed. The continuous statement is used to skip the current iteration of the loop and proceed with the next iteration. This is useful when certain conditions are met, and you don't want to execute the remaining code of the loop for that iteration. So here we can clearly see we start the loop and met the condition. If the condition is true, the continuous statement is executed, and the print function is not executed. We won't print anything. We return to the beginning of the loop, skipping the print function. But if the condition is not met, we execute the print function within the four loop and only after that, we will continue the loop. I slightly change the code we had before. I replace break operator with continua, a bit rewrite message. And run again the code. And here we can see that we skipped the number seven. The second print function with number didn't print anything. We see on the message that we should skip the seven. And if I remove this print, because it was just information for us. And now I run again the code, we see that condition was met and seven is not here. We printed the numbers as long as the condition was not met and we kept getting false. As soon as we got through, the continuous statement was executed and we skipped the print, but we only skipped printing the number seven. The loop continued to run, and we printed the other numbers. You can use break and continuous statements to control the flow of a le loop. Here, I initialize a variable I and set it equals to zero, this variable will serve as a counter to control the loop. The while loop will continue to execute as long as the condition I less than ten is true. In this case, the loop will keep running until I equals to ten. I print the value I for each iteration. Inside the loop, we have condition to break this loop. This I statement checks whether the value of I is equals to five or not. If this condition is true, the break statement is executed, and it immediately terminates the loop and here I increment the value of I by one. Here we can see the plus equal operator. It's an example of a compound assignment operator. It's a shorthand way to both add a value to a variable and assign the result back to that variable in one step. In other words, in place addition. It's a quick way to say increase the value of this variable by a certain amount. We also have the minus equal operator. It works similarly. But instead of adding, it subtracts a value from the variable and assigns the result back to the variable. The loop starts at I equals to zero and increments I by one each time. When I equals to five, the break statement is executed, ending the loop. I'll replace the shortened version with the full one, and we'll get the same result. Even though the condition is still I less than ten, the loop stops early because of brake operator. Let's move on to the continuous statement. I slightly change the code. If I run this code, we can clearly see that the loops keeps printing five because continua is called when I equals to five. This operator stops the current iteration, so print I is not executed when I equals to five. Our loop then moves to the next iteration, continuing with I equals to six, and here we can clearly see it. By using break and continua, we can control the loops execution. You can handle many different scenarios with the le loop. But not only with while also with fore loop, combining for and while loops with nested if conditions, provides flexibility and control, enabling you to iterate efficiently while applying logical checks. Both break and continuous statements are used to control the flow of loops. Whether you're using while or for loop, it does matter. They help manage the execution within loops, but behave differently in terms of how they influence the loops operation. The break statement is used to exit a loop permanently. The continuous statement is used to skip the current iteration and move on to the next one without terminating the whole loop. It causes the program to jump to the next iteration of the loop. The choice between break and continue depends on the logic and flow control requirements of your specific tasks. 17. Working with Nested Loops and Conditional Statements in Python: Welcome back, guys. We are going to dive into an important concept in programming nested loops and conditions, why they are useful and how you can apply them to solve more complex problem. In programming, loops allow us to repeat a set of actions and conditions, let us make decisions in our code based on certain criteria. We learned it before in the previous lesson. But when we talk about nested loops and conditions, we mean that one loop or condition is placed inside another. Let's start from four loops. I'm going to create two lists. The first one will be animals, and it contains three species. The second will be habitats. We will use these two lists to generate combinations of each animal with each habitat. So in the first loop, we iterate over each item in the animals list one by one. During each cycle of the four animal in animals loop, the variable animal will take on the value of each element in animals and sequence. On the first iteration, animal will be lion. On the second iteration, animal will be penguin. On the third iteration, animal will be elephant. Then we have inner loop. This loop is nested within the outer loop. For each animal, the inner loop will run through every habitat in the habitats list. Each time the inner loop runs, the variable habitat will take on the value of each element in the habitats list one by one. This inner loop repeats for every single animal from outer loop, meaning it will go through all habitats for each animal. For example, when animal is lion, the inner loop will iterate three times, setting habitat to Savanna, then Antarctica, and finally grassland. Print statement runs each time the inner loop executes. It will print the current values of animal and habitat. We will see it together as a pair. This creates all possible combinations of animals and habitats. So the first iteration of the outer loop animal is lion and then inner loop with animal as lion, we have habitat is savanna, so it brings lion savanna. Next, habitat is Antarctica, so it brings lion Antarctica. Finally, habitat is grassland, so it brings lion grassland. Second iteration of the outer loop animal is penguin, and then inner loop does the same job. We have penguin savannah, penguin Antarctica, and penguin grassland. With third iteration, we have animals as elephant, and the inner loop again, does the same job. The result of this masted loop structure is a combination of every animal with every habitat. Let's go on with nested conditions. Nested conditions allow us to check for multiple levels of criteria. For example, here I initialized two variables A and B. These varias will be used to evaluate the conditions in the code. Then I write the code with nested conditions. This is the first condition, and here we check if the value of A is greater than ten. This condition is true, so the code inside this Eblock will be executed. Now that we are inside the I A greater than ten block, we encounter a second I statement. The second I is nested within the first one, meaning it only runs if the first condition A greater than ten is true. In this nested IF, we check if B is greater than two. Since B is five, which is indeed greater than two, this condition is true as well. So the code inside this nested I block will execute. Since both A greater than ten and B greater than two are true, the program reaches this line, and it will print the message. A is greater than ten and B is greater than two. This output indicates that both conditions were met. This s clause is attached to the inner condition if B greater than two. It provides an alternative outcome if the nested E statement, if B greater than two, had been false. In other words, if a greater than ten is true, but B greater than two is false, the program would execute this Ls block instead. Since B greater than two is actually true in our case, this s block is ignored in this run. The ELs block is attached to the first outer If statement. The initial check, A greater than ten where false meaning A was ten or less, the program would skip all the inner code and jump straight to this els block and it would print A is not greater than ten. Since A is indeed greater than ten, in our case also, this els block is also ignored in this run. Let's consider nested wile loops. This nested loop structure is useful for tasks that involve grids and tables where you need to iterate over both rows and columns. So the first I initialize the variable role and set it to zero. This variable will represent the role in our grid like structure. Then I write outer wile loop. This loop runs as long as row is less than five. Since row starts at zero, the loop will continue until row reaches five for each iteration of the outer wire loop, we go through a complete cycle of the inner loop. We inside the outer loop, and before we enter the inner loop, we set column 20. This reset ensures that for each neuro, we start the columns from the beginning. So the column equals to zero. As we understand, the column variable represents the column position in our grid. Now, let's write the inner i loop. This inner loop controls the column values. It will run as long as the column is less than four, meaning each row will have four columns, ranging 0-3. For each row, this inner loop will run four times greeting four column values for each row. Inside the inner loop, this line prints the current row and column values. This gives us positions for each cell in the grid with the format, row and column. Since the inner loop runs four times for each row, it will print four column values for each row value. After printing the current row and column, we increment column by one. This increment is crucial to move to the next column. Without it, the inner loop would repeat infinitely since column would always stay at zero. Once column reaches four, the inner loop condition, column less than four becomes false, ending the inner loop and returning control to the outer loop. And here when we return to the outer loop, we increment role by one to move to the next row. This process repeats until row reaches five, at which point the condition row less than five in the outer loop becomes false, and the entire program stops. The output for this code will print all possible combinations of row 0-4 and columns 0-3, creating a grade of coordinates. Nested wi loops can be used to manage the game state, iterator possible moves, or check conditions across the board. See you in the next lesson. 18. Using Loops with Nested Data Structures in Python: Welcome back, guys. Today, we will be working with nested data types, and we start from nested list. Nested list is a list that contains other lists as its elements. We can think of it as a list of lists. In this example, we are using the nested list called grid that represents three by three grid. Each inner list represents a role, and each individual element in these lists is like a cell in the grid. Here in the grid has three rows and three columns. In this structure, grid zero accesses the first row. Grid one accesses the second row and grid two accesses the third row. The axis individual element within a specific row in a column, you can use double indexing. For example, grid zero, zero, retrieves the element at the first row and first column, which is one, and grid one and two retrieves the element at the second row and third column, which is six. To print each element in the grid, we can use the nest for loop that we saw in the previous lesson. The outer loop iterates through each row, while the inner loop iterates through each element within that row. The print statement prints value followed by a space rather than a new line. This is to ensure each rows value are printed on the same line. After the inner loop completes for each row, print is called without any arguments. This line break ensures that each rows value are printed on a new line. And here we can see the result. This output shows the values in grid printed in three by three grid format. Nested lists allow for multidimensional data representation. But we also have other data types. Let's continue with NASDA dictionaries. NASDA dictionary is a dictionary that contains other dictionaries as its values. This structure is helpful when you want to store related data about multiple entities and need a way to organize complex information. In this example, we have a dictionary called users where each key represents a unique user ID like user one, user two, and each value is another dictionary containing details about that user. This dictionary, the outer dictionary keys, user one, user two, user three, identify different users. Each value associated with these keys is an inner dictionary that holds the user's details like name, age, and location. We can get access. For example, User one returns name, Nick, and all information about this user to access Bob's age. For example, we can use users User two ag and it returns 30. To print each user's information, we can use nested loops to iterate through both the outer and inner dictionaries. Here's how it works. Users items returns each key value pair in the user's dictionary. We saw it in the previous lesson. And in this loop username holds the outer dictionary skey User one, user two, and details holds the value for each user, which is the inner dictionary containing that user's information. The loop iterates over each user in users. So the code inside the loop will run three times, once for each user. For each user, we print the user name value as a header. The user name gives us the Ater dictionary skey representing a unique user ID. Then we have inner loop. Here we have details items, and it returns the key value pairs in the inner dictionary. For each key value pair in the inner dictionary, key holds the specific detail type name age location, and value holds the actual detail value. The inner loop will run three times for each user since each user dictionary contains three keys. Inside inner loop, we print each user's detail in a formatted way. Key capitalize, we used to capitalize the detail name for more readable output. And then after the key, we printed the value. This allows us to print the user's name, age, and location in readable format. After the inner loop completes, we use print with no arguments to add a line break, visually separating each user's details. So the first outer loop iteration for user one and we have username user one. Then we have details like name, age, and location, and inner loop prints it then second outer loop iteration for user two, and we got user name user two and then inner loop prints details. And the same we see with third outer loop itration just for user three. We can use NAT and dictionaries for structured storage of complex data. It makes easy to access and display information hierarchically. Let's dive deeper, and now we are combining nested lists and dictionaries. We can combine various data types in a nested structure. For example, here we have a list of dictionaries. The products variable is a list containing multiple dictionaries. Each dictionary represents a single product and contains key value pairs for different attributes of that product. Specifically, each dictionary has three keys, name, price and stock. Which store details about the products name, price and quantity and stock. So in products, we have two dictionaries, each representing a unique product with its details. The four loop iterates or each dictionary within the list, and here product is a variable that will take on each product in products one by one. And during each iteration, product refers to one dictionary. Inside the loop, we use the product dictionary to access each attribute like name price and stock of the current product dictionary by its key. Product name accesses the value associated with the name key. The same we have with product price and product stock. And eventually, here we have outputs in readable format. And let's work with a list of taples. I initialized the variable grocery items, and this will be a list of taples. Each tauple contains two elements, the name of the grocery item, and its corresponding price. The outer structure is a list, which allows us to store multiple items in an ordered collection. Each item in the list is a double data structure that can hold multiple vos. Tables are immutable, and we know that from previous lesson, it means that once they are created, their varios cannot be changed. This is very useful for representing fixed pairs of data. Each double in the list had the following structure, item name and price. And then I write the four loop. The four loop iterates over each double in the grocery items list. Here item and price are variables that will take on the variables of each tuples, first and second elements respectively during each iteration. This unpacking process means that for every iteration, item receives the name of the grocery item, and price receives its corresponding price. Inside the loop, the code uses the unpacked values to print formatted output. Item inserts the name of the item into the string. Price inserts the price from to two decimal places, ensuring that it always display as a monetary value. When this loop runs, it will produce the following output for the given list of grocery items. Combining different types of nest and data is particularly useful in real world application. By utilizing NSTA structures, we can create complex and organized data representation that enhance our ability to manage information. For now, that's all. See you in the next lesson. 19. Understanding List Comprehensions in Python: Welcome back. List comprehension is a powerful feature in Python that enhances code efficiency, readability, and expressiveness, making it preferred choice for many developers when working with lists. This can make your code cleaner and more readable. It allows you to express complex list generating logic in a single line of code, significantly reducing the amount of code you need to write. So I initialized a new value, and here we have four loop. This loop iterates or a range of numbers generated by function range 11, which produces the integers 0-10. The variable X takes on the value of each integer in the sequence one by one, and the append method is used to add each value of X to my list. After each iteration, a new number will be added to my list. The loop runs 11 times csponding to the numbers zero through ten. As a result, we can print and see we've got list. However, we can write this in a much more shorter, faster and more readable way. I initialized the variable Mil list two. List comprehension is a concise way to create lists by combining a loop and an expression in one line. We have X. The the value that will be included in the new list. It represents each number in the sequence. For each number in the range, the value X is directly added to the Mist two. In my case, since there is no additional condition or transformation, the list comprehension simply creates a list of the values generated by function range. And if I print this code, we see two totally identical results. We can generate a new list by applying an expression to each element in an existing iterable. It can be tuple, string, or list, and optionally, we can filtering elements based on a condition. This approach combines both the duration and conditional logic in a single readable line. But if I slightly change this code, I can show you how we can apply an expression to each value one by one and create new list from function range. But instead of previous result, I can see not just value X added to the new list, but square of X. There's expression that specifies what each element in the new list should be. In this case, X calculates the square of X. The operator in Python is used for exponentiation. So here we have square numbers. The syntax is not that complicated. Whatever we have on the left will be added to the list with each iteration of the four loop. Let's consider more examples. Here are created a list named towns that contains five strings, each representing a different city, New York, Los Angeles, Chicago, Houston, and London. Then we initialize an empty list called new list. This list will eventually hold the cities from towns that don't match the specified city in New York. And here I write for loop. This loop iterates over each element in the town's list. The variable X takes on the value of each town in the list during each iteration. Inside the loop, we check if X is not equal to New York. If the condition is true, we execute the next line. If the condition is met, we use the Band method to add X to the new list. This builds new list by including all towns except New York. If I run this code, I've got like this. Here we have new list where we can see all cities, except New York. But let's rewrite it. This operator is comparison operator. In programming that stands for not equal two. It's used to compare two values or expressions to determine if they are different from each other. It the values being compared are not the same. The operator returns true value. In other case, it returns false. So let's write our first list comprehension. This line does the same thing at the four loop, but in a more concise and pythonic way, the general structure of list comprehension. And here X is the expression. For X in towns, it reads over the towns list just like the four loop. Then we have row that filters out any towns that are New York this line creates a new list directly using the tons that meet the conditions specified in the list comprehension. So if I print this code, we can see the result. Let's make the task a bit more challenging. Here I write is comprehension with condition. X is the value that will be included in the new list, as I said before, then we have four loop that does the same thing as previous. But then we have condition. This is a filter that only includes numbers where the expression evaluates to true. In this case, it checks if X is even by using the module operator. It checks if the remainder that X is divided by two is zero. The list comprehension it reads through each number generated by function range, which are zero, one, two, three, and through ten. If X is even, it's included in the new list events. If X is odd, it's excluded. As a result, we have like this. I can rewrite it as a traditional for loop, but in this case, we have included If statement. And only after this condition, if it's true, we abandon e value to new list. So if I run this code, we will see totally identical results. Here I create a list called words containing three strings. Hello, world and Python. Let's imagine I want to know the length of every word in this list. I want to have the final output as a list of integers representing the number of characters in each corresponding word. And for this, I can use the list comprehension. From the left, we have the A function that calculates the length of each string in the list. This function returns the number of characters in the string. Then we have iteration for word in words. This part iterates over each element in the words list. The variable word takes on the value of each string in the list during the iteration. So the comprehension iterates over the words list. For each word, it calculates the length using an function. The resulting length are collected into a new list called length. And we have for hello, the length is five, for world, the length is five, and for Python, the length is six. Let's go with advanced example. I want to consider an example of an advanced list comprehension that uses multiple conditions within a single line. I initialized empty list numbers. Then I have four loop that iterates over numbers 0-50, and inside four loop, I have several statement. The first one will check if X is divisible by two. The second we'll check if the X is divisible by three, and the third one checks if the X is divisible by five. If X meets all condition, it's added to the numbers list. The output would be like this. How can we write it in shorter way? From the left, we have entity that we will add to the list. Then we have four loop where we generate number 0-50, and then we have conditions one by one. I'll make it more readable like this. At the end, I print numbers and we have the same result. Let's consider another example where we will have two included four loops. I define variable grid, and here I save two dimensional list. Then I initialize my list. It's just empty list. That will hold the flattened result of grid. Eventually, my goal is create from two dimensional list just one dimensional list. In the first four loop, I iterate over each sub list. So in the first iteration, I will have the first row one, two, three. In the second iteration, I will have row four, five, six, and in the third iteration, seven, eight, nine, In the inner loop, I iterate over each element in the current row. So here, my variable column takes the value of the current element in row. For the first row, my variable will be one then two and then three. For the second row, it will be four then five, then six, and for the third, it will be seven, eight, nine. And then I use the append method that adds each element from row to my list one by one. By the end of the nested loops, my list will contain all elements from grid in a single flattened list. Now it's time to write it as a list comprehension. I initialized, again, my list. As always, on the left, I specify that will be added to my new list with each iteration. Then following the logic of code execution, I specify the two loops as described above. I just copy it. And here we are, we have the same result. Both methods give the same final output, but least comprehension is often preferred for its conciseness and readability when dealing with simple transformation like this. The last but not least example of list comprehension with ELs block that categorizes a list of numbers based on whether they are divisible by three or not. I initialized variable numbers. Then I created an empty list to store the results. The first row, it's for loop. It iterates over each number in the number list, and then I have If Ls condition. If X is divisible by three, the string divisible by three is added to categorized variable. Otherwise, we will add the string not divisible by three. And at the end, we print the result for each number. In the last, we obtained the corresponding value indicating whether the number is divisible by three or not. Now, let's write it as the least comprehension. It may seem a bit confusing, but it's not difficult. Look, we have from the left the entity that we are going to add to the new list, but it's not just entity. We're going kind of filter it. And here we use EFLsblock condition. This conditional expression checks if each number X in numbers is divisible by three. Then we have loop that iterates over each element in numbers. We have left and right parts of expression. On the right, we have a loop, and with each iteration, we check the value of X. On the left side, we have AFLsblock. Depends on X is divisible by three or not. Divisible by three or no divisible by three will be added to the new list. Eventually we have new list where we just replaced every value depends on it is divisible by three or not. With this example, I just wanted to demonstrate how the AFL statement works in list comprehension. See you in the next lesson. 20. Using the input() Function in Python: Welcome back, guys. How can we receive information from a user? In programming, there is input function. It allows the user to provide data that the program can then use to perform calculations. For example, a program might ask for user's name and then greet them personally based on that input. The purpose of input is to make applications interactive. Input is used to read the value, enter it from the keyboard. For example, I write the input function, and inside, I use the prompt. It can be optional. It's string that is displayed to the user to guide them what to enter. In my case, it will be name. Now, if I run this code, my little program will ask me enter my name. I input my name, press enter, and for now, my program has finished working. I'm going to assign this line of code to a variable and I will call it name and then I'm going to print it. Here I use concatenation. You already know what it is. Now I run this program again. Again, I input my name, presenter, and here we are, we got greeting. The input received from the user is always treated as a string. That's why we use here concatenation and it works. Therefore, if numerical input is needed, we need some preparation. We need type conversion. Let's consider such example. The first line of code is a title or a label to indicate to the user that the program will perform an addition operation. Then the second and the third lines of code will ask user to enter two values. Then I print result. This result I assigned to the new variable. If I run this code, you will see something strange. I remind you that two inputs treated as a string. So here we had not adding bad concatenation. Here we need to convert the input from string to integer using function. But if I run this code, I will get an error again because here in print function, we concatenate two different types of data, string and integer. If I convert result again to the string, I will work again. But we can omit this step. We can use not concatenation but commas to output as a taple. It separates values by commas in the print function. And when we using commas in print function, it automatically converts each item to a string as needed, so there is no need to use string function. This is often more convenient, especially when combining different data types. As I said before, Python reads user input as a string by default, but we can convert this input into various data types depending on what we need. We just considered integer conversion, but we can use other types of data as well. For example, let's consider boolean conversion. It's often used when you want to interpret the user's input as true or false value. Typically in cases where the input is yes or no question. So here, user is asked a question, I initialize a variable to store the user's response. As a prompt, I use simple question, do you want to continue? Usually, the answer is yes or no, but it can be different type of yes or no. So I'll create the second variable and here will be conversion. Here I use lower function. This part of the code is used to ensure that the users input in lower case. This is important because user input can vary in capitalization, and by converting it all to lower case, we can treat all variations consistently. After converting the response to lower case, the expression checks if the lowercase input in the list of accepted true values. You can expand this list with your own options. If the lowercase input matches one of these values, the expression returns true. Otherwise, it returns false. This logic is a quick way to handle various forms or positive responses without needing multiple if statements for each possible variations. The Boolean variable is continua, now contains either true or false, based on the user's response. When print function is called, Python automatically converts the Boolean value stored in Ictinua into string representation. If I run this code and I input one, I will get true if I input, Yep. I also will get true. And eventually if I input no, I will get false. As an example, I also want to show you how to convert into float. If you want to handle decimal values such as prices or measurements, it will help you to know how to convert into floating point number. Here the program asks the user to enter price one and price two, and then I calculate final price and print. The input which is captured as a string, then convert it to floating point number using the float function, and here I use it for two prices. This allows me to perform numerical calculations with the value I will get. So let's run this code. I entered two values with decimal part. Press center, and I've got the result. Eventually, as a result, we performed a mathematical operation to calculate the total cost of two products by first converting user inputs into floating point numbers. In the next lessons, we will see how these can be used in more complex code structures. And for now, that's all. Thank you. See you in the next lesson. 21. Introduction to Functions: How to Create Functions in Python: Welcome back, guys. We are starting a new section where we will work with functions. In programming, functions are fundamental building blocks. It's a reusable piece of code that performs a specific task. Let's start from simple example. We already familiar with print statement. Let's imagine I won a grid three times. If I run the code, we will see three greeting messages. Let's rewrite it a bit. The function in Python is defined using the DEF keyword followed by function name and parenthesis. Here's the basic syntax. Then I write the body of a function. It's the block of code that contains the instructions that the function will execute when it's called. It's defined by the indentation level following the function declaration. The code within the function must be properly indented to indicate that it belongs to the specific function. If the body of the function is not indented properly, Baten will raise an indentation error. In this case, body of the function will be only print statement. I define a function named grid that when called will print a message to the console. If I run the code now, we won't see anything. To execute the code inside the function, you need to call it by using its name followed by parenthesis. If I now run this code, we will see the greeting. And this function will print the greeting as many times as I call it. This saves me from having to copy the greeting repeatedly. In Python, parentheses are essential when calling a function. They indicate that you are invoking the function, and without them, the function will not run. If you indicate the parenthesis, Python understands that you're requesting the function to execute. If you attempt to call a function without parentheses like this, this doesn't call the function. For example, here I called function only two times and the third one didn't execute. Function can also accept arguments. Arguments are values that you can pass to a function when you call it. This allows you to provide input data that the function can use. For example, in my greeting function, I can add a parameter name, and then I can use it inside the function. If I run this code, right now, I will get an error. When a function in Python is defined with parameters, in my case, this parameter is name, it means that the function expects certain values to be provided when it's called. If I attempt to call a function that has parameters but don't provide the required arguments, Python will raise an error and we see it clearly now. So I pass the argument Helga and Valla everything works. Now we have flexibility. The function produces different outputs based on the argument we provide. Each time I call the function, I can pass different name. The function will use that name in its operation, allowing it to generate customized greeting. I can specify multiple parameters. This means that the function can accept more than one argument when it's called. In my case, I add a number and then I use it inside the function. However, when calling the function, it's crucial to provide all the required arguments. If I call nowadays function, I will get an error. Because I have only supplied the first parameter name and have omited the second parameter number. So I add it, and if I run this code now, everything works. At this point, we have only considered printing inside the function. But let's consider the return statement. The return statement in Python is used within a function to exit the function and send the value back to the place where function was called. It effectively terminates the function's execution and can pass data back to the color. Well let's start from simple example. I write the function that adds two numbers. The add function is defined to take two parameters, A and B, and it returns their sum. When you call the function with five and three, it performs the addition and returns the result which is eight. However, it's important to note that simply calling the function does not display anything in the console. This is because the function's return value is not automatically printed. The return value is generated, but unless you capture or print it, it remains invisible to the user. To actually see the result in the console, you need to assign the return value to a variable and then print it. In this case, we will see the result in the console. Let's look at more advanced example. I want to create a function that takes a name as input and returns it with the first letter capitalized. I call it capitalized name and it takes one parameter name. Inside the function, the capitalized method is used to make the first letter uppercase and the rest lowercase. We learned this method when we worked with string data type. Then the function returns the capitalized name. Then I call the function and capture the return value to the result. I intentionally passed the name in lowercase. And eventually I print it. The function works with any name, ensuring that only the first letter is capitalized, making it easy to format names properly. One of the main thing to understand, we can use the function within another function. This is called function composition using one function inside another to build more complex behavior. Suppose we want to create a greeting function that uses our capitalized name function to ensure that name is properly formatted. So the grit user function uses capitalized name function to make sure that name is capitalized before it creates the greeting. After capitalizing the name, Grit user function creates a greeting message with the formatted name. So I initialized the variable formatted name and assign it to the result returned by the function capitalized name. This is made possible by the return statement, which provides the result of the functions work. I remind you that this result is capitalized name. Then I used this capitalized name in greeting message, and at the end, I return greeting message. So I can call the main function. In my case, this grit user function. I initialize the variable message and call the function grit user. It calls capitalized name function first to get capitalized name, then build the greeting. By composing functions in this way, you can keep your code organized and reusable. Each function performs a specific task, and you can combine them to build more complex functionality. If I run this code, I will get formatted message. Functions in Python are first class object. They can be assigned to variables, stored in data structures, passed as arguments to other functions, and even returned as values from other functions. They can accept an arbitrary number of arguments or take none at all. Function allow you to use the code encapsulated within them as many times as needed during the execution of a program. If I comment out the function capitalized name, I will get an error when I try to call capitalized name in grit user function. This is because grit user function depends on capitalized name function, but with the function comment out, capitalized name no longer exists causing an error when grit user tries to call it. The past statement in Python is a placeholder that does nothing when it's executed. It's often used in function as a temporary body. When you need to define a function but haven't yet written any code for it, it allows the code to run without error. Even though the function is currently empty, the past statement allows you to define functions or control structures that don't do anything yet without causing errors. It's useful for keeping code organized and error free while you're building or planning parts of your program. Because the capitalized name function currently does nothing, it only contains the past statement. There is no value being returned. When a function doesn't explicitly return a value, Bydn automatically returns none by default. When we call grit user, the grid message got none, and here in the console, instead of the name, we see none. In the next lesson, we will discuss the different types of arguments and functions and how to work with them. For now, that's all. See you in the next lesson. 22. Positional and Keyword Arguments in Python Functions Explained: Welcome back, guys. Now we will discuss what positional and keyword arguments are in a function. Let's start with positional arguments. So let's imagine you have a function and you want to pass data to it for processing. However, how will the function know exactly what data you are passing? This is where positional arguments of the function come into play. Let's imagine you are writing a program to add two numbers. This function should take two numbers and compute the sum. So I write the function, then I initialize the variable and call the function. The first pass value becomes the first number for sum calculation, and the second pass value becomes the second. If I print the result, we will see the sum of these two values. So positional arguments are passed in the same order as they are specified in the function call, when calling the function, values are passed without specifying the parameter name. What's the parameter name, and here we move to key ort arguments. Keyort arguments are given as key value pairs where each argument has its own name. This allows you to pass arguments in any order, even if they were defined in a different order in the function itself. This means that we specify not only the values of the arguments, but also their names. When calling the function, this approach allows us to pass arguments in any order and clearly communicate the purpose of each argument. Now let's consider an example. Suppose we have a function that calculates shipping costs based on weight and distance. We can use keyword arguments to pass these values to the function. So I write the function itself. I'll call it calculate shipping cost and pass weight and distance as arguments. First, we will define the rates, one 4 kilograms and another 4 kilometers. Then using a simple formula, we will calculate the shipping cost by multiplying the weight by the rate per kilogram and adding the distance multiplied by the rate per kilometer. Finally, we will return the calculated result. Now, let's call the function and plus the key ord arguments for weight and distance. We will also print the result we received. If I run this code, we will get the result delivery cost. So in this example, we pass the values weight and distance to the function, which we called calculate chipping cost as keyword arguments. This allows us to clearly see which value is used for what when we read the code. If I remove here name of keywords, actually, I will mix position on keyword arguments. I can pause the weight as just a number and pass the distance as a keyword argument. In this case, ten will be passed as a positional argument weight, and 100 will be passed as a keyword argument distance. And this is called a combination of positional and keywort arguments. We can use both positional and keyword arguments when we calling a function. However, positional arguments must come first. They must always be before keyword arguments. You cannot specify keyword argument first. For example, if I write late equals to ten and then just specify distance and put it 100, this would be an error because in this case, the first argument is keyword argument and the second positional, and we know that we cannot do this. I will return everything to how it was. Keyword arguments make our code more flexible and understandable, especially when dealing with more complex functions where you may not always remember the order of all parameters. In the example above, we discussed positional arguments where we must remember their order. When using positional arguments, the value we pass must match the parameters declared in the function and we cannot confuse the order of the arguments. With keyword arguments, however, we have more flexibility. For example, we can specify distance, first, when passing it as a keyword parameter and then provide the weight regardless of the order in which we specify them, our data will be assigned to the corresponding parameters based on their names. I'll give another example to make it clear. I'll create a function grit, where we will pass parameters name and message. And here we will return a greeting. In the first time, I'll pad the parameters as positional. They need to be passed in the order in which they are defined in the function. I'll remove the extra and print the result. As a result, we have greeting, then name and message at the end. We swap our parameters, we will get an invalid result. And in this case, we just received not very valid message. But if this applies to some formula, we can get a completely unexpected result. According to the algorithm, we were supposed to greet a specific person by name first, and then the message parameter should have followed. What we actually did was greet with the message. Well, it somewhat fits the meaning, but imagine if the message were completely different and then jot the person's name at the end. Not very but if I pass these parameters as keywords, that is I specify name and message specifically, then regardless of the fact that they are not in the same order as passed to the function, we will get a valid result. I will remove the print statement from the previous function so it won't interfere. Here I added a default parameter. Keyword parameters can be optional if they have default values. If I already specified the message in our function and I delete this parameter from function call, we will still get a valid result. But with this parameter hello that I specified before, that will be the default value. If we don't specify the message at all, it will be used in any functional call. Let's quickly go over the key concepts and differences between positional and keyword arguments. Positional parameters are passed to the function in the order in which they appear in the function definition. If you pass these parameters in reverse order, it will affect the result. They are also mandatory, meaning they must be provided in the order in which they are defined in the function and always before any keyword arguments if both types are present. On the other hand, keyword parameters are passed to the function as key value pairs. Each argument has its own name. Keyword parameters don't need to be passed in a specific order. They can be provided in any sequence. They can also be optional if they have default value. This means that if we specify keyword parameter with a default value in the function definition, we can emit it altogether when calling the function. While positional arguments, if defined must be passed to the function in the exact order specified in the function definition. As you can see, understanding the difference between keywords and positional parameters is crucial for effective use of Python functions. And for now, that's all. See you in the next lesson. 23. Using Argument Packing and Unpacking in Python Functions: Welcome, Ben guys. I apologize for the inconvenience. I switched from the sd to Pycharm. I had to change my workspace. This was a necessary measure. I hope it doesn't cause too much disruption. Now I'm going to discuss about packing and unpacking the arguments in a function. Packing, the process is gathering multiple arguments into a single tapple or a dictionary. Unpacking the process of distributing or extracting those arguments from a taple or dictionary when passing them to a function, or when calling a function with a list or dictionary. When you use Asterix arcs in function definition, it allows the function to accept a variable number of positional arguments which are then puged into a taple. You can pass a varying number of arguments to a function in the form of list, tuple or set, and all of these will be converted into a taple through the Asterix arcs operator. Similarly quarks allows for unpacking of keyword arguments from a dictionary. The key value pairs in the dictionary are unpacked and passed as keyword arguments to the function. So let's go practice. I initialized the variable my list. Containing four elements one, two, three, four, I remind you that lists are ordered collection in Python, meaning that the items have a defined order and can be accessed by their index. The second line of code performs unpacking. It assigns the values from my list to the variables A, B C, and D. Here's how the unpacking occurs. A gets the value one, the first element of my list, B gets the value two, the second element, C gets the value three and D gets the value of four. The number of variables on the left side of the assignment ABCD must match the number of elements in the list. If they don't match, Python raises a value error. Now let's print the result. I run the code, and here we are. We obtained the result in the same order as we listed the variable in the print function. If I print A, we will get one. If I print D, we will get four. Let me show you what will happen. If there are fewer variables on the left, then there are objects in the list. We got an error, too many values to unpack. In case we don't know how many elements will follow after unpacking, but only need the first two elements, we can use the operator asterix. This way, the first two elements will be unpacked into the first two variables, and the remaining elements will be assigned as a list to the variable C. Let's print it and we see the result. The same applies if I only want to get the first value, I'll keep the variable A for that first element, and the remaining elements will be backed into the variable C. Let's consider an example with tuple. It works exactly the same. Here I defined tuple. It contains three elements. I remind you that tuples are ordered collections in Python and are immutable, meaning their elements cannot be changed after they are created. And here I'm unpacking the elements into three variables. Let's bring the result, and here we are. We will look at the case where we want to get the first value during packing, leaving the remaining elements in a variable as a list. But what if we want to get the last variable during unpacking? The Asterisk seperator placed in front of A, collects all elements, except the last one while the last element is assigned to B. A will capture the elements two and three in a list. B will capture the last element, which is four. Now let's look at how this works in functions. Here I defined the function. The arcs parameter in the function definition allows funk to accept any number of positional arguments. The asterix before arcs pugs all extra positional arguments into a tuple named arcs. This means that when funk is called with multiple arguments, they are collected in arcs as a taple. Then I have loop. These loop iterates over each element in arcs, and I represent each argument passed the funk one by one. Here I'm calling the function. I passed four arguments, but it can be as many as you want, and also it can be any datatype. If I run the code, we will get all arguments. Thanks to this construction, I can pass any number of positional arguments to the function without causing any errors. Let's consider another example. So I write the function. The quarks parameter in the function definition allows the function to accept variable number of keyword arguments. Double asteris before quarks means that any additional keyword arguments passed to the function will be packed into a dictionary called quarks. These quarks will contain the names and values of the arguments passed where the keys are argument names, and the values are the corresponding argument values. We already know that items method on a dictionary returns pairs of keys and values, which allows the loop to access each keyword argument and its corresponding value and quarks. Inside the loop, I print each key and its associated value on a new line. Then I call the function and pass the keyword arguments. When funk is called, quarks becomes a dictionary. And then for loop iterates over quarks items and printing each key value pair. This technique is useful for creating flexible functions that can handle different sets of named arguments. You can use it in this situation where you are building functions that work with dynamic data or collecting data without knowing in advance what field will be passed. Let's consider another example with dictionary unpacking. Here I defined a dictionary. This line creates a dictionary named data with three key value pairs, name age and city. The dictionary holds data that will later be passed as arguments to a function. Then I define the function and this function will need three parameters, name age and city. Inside the function, I will print the values of these parameters in a formatted string. Let's unpacking the dictionary and calling the function. When calling funk, the double asterix data syntax is used. It unpacks the dictionary so that each key value pair in data is passed as a separate keyword arguments to function. Essentially, double asterix data translates the dictionary into individual keyword arguments as if you had written like this. Here's what happens during the unpacking. The dictionary key name is matched with the name parameter. Name gets the value own. The key H is matched with the H parameter, so H gets the value 23. And the key city is matched with the city parameter, so City gets the value LA. You can use it if you have a function that requires multiple arguments and you want to pad them from a dictionary with matching keys. This technique is called dictionary packing and makes your code flexible and more maintainable, especially in complex applications where data often comes as a dictionaries. For now, that's all. See you in the next lesson. 24. Understanding Lambda Functions in Python: Welcome back, guys. Let's look at Lambda functions in Python. Lambda functions in Python are powerful feature that allows for creation of small anonymous functions at runtime. It provides a way to write short, throwaway functions without the need for a formal function definition using the DEF keyword. Lambda Keyword indicates that you are defining a Lambda function. Then we have the arguments. These are the input parameters for the function. You can have multiple arguments separated by commas and expression. This is a single expression that the Lambda function evaluates and returns. Unlike regular functions, amda functions can only contain a single expression and cannot include multiple statements or annotations. They don't have a name and it makes them anonymous. This is useful for short functions that are not used elsewhere. Let's consider the first example. In this example, is a Lambda function that takes two arguments, X and Y and returns their sum. The Lambda function is called with three and five, resulting in eight if I run this code, we will get the result. If I rewrite this function as usual function, this can look like this. Here we have two identical functions. Let's consider Lambda function with no arguments. You can also create a Lambda function that takes no arguments. The Islamdafunction return a greeting message and can be called without any parameters. I want to note that the entire function is assigned to the variable grid. This line essentially saying, store the Islamda function in the variable grid. At this point, grid is not holding a standard value like a string or number. Instead, it's holding a reference to the Lambda function. Because grid holds a Lambda function, it's considered callable. In Python, any variable that can be called a function using parenthesis is considered callable. When I place parenthesis after grid, Python treats this as a function call and attempts to call whatever grid refers to. We cannot do this with usual variable that refers to string or float. It works only this way. So here we can clearly see that Python treats function as a first class object and allows variables like grit to be treated as a function names when they reference functions. Let's bring the function to see the result. Lambda functions are often used with built in functions like MP filter or sorted. Let's consider the map function. In Python, map function is a built in function that used to apply a specific function to each item in an interval like staple or set and return a uterable with a result. We can use this function when we want to perform the same operation across multiple items in the collection. In my case, as an iterable, I have a list, and as a function that I'm going to apply to this list, I write Lambda function. In this example, the Lambda function squares each number in the list. As you may have noticed, I also used the list function. By mapping MAP function with list function, I force the map object to generate all its items and store them in a list. This lets me see the output immediately and work with the results like any other list. If I run this code, we will see the result immediately all at once. Let me quickly show you what this code would look like without using a Lambda function. I will use the same number list, but I rewrite it as a usual function. First, I rewrite the Lambda function and I call it square. Then I initialize the variable squared, and it will be empty list. I will save here new elements. Then I write four loop, go through every number in my list, make it squared, and append to the new list. Let's bring the result These should be the same values. So if everyone is good, here we are. The Lambda function allowed all this code to execute in a single line, and sometimes it's very convenient. I will slightly increase the list. Let's consider the example with filter function. The filter function is also built in function in Python, and it used to filter items from an iterable like list or temple based on a specific condition. The basic syntax is almost the same. But in this case, I will use the expression that will check if X is even so we define a list numbers containing integers. We created a Lambda function that checks if a number is even. We use filter to apply this Lambda function across all items in numbers, which filters the list to keep only even numbers. Then we convert the filtered result into a list and store it in even numbers. Finally, we print the list of even numbers. So if I run this code, we will see the new list with only even numbers. Lambda functions in Python are powerful tool that allows to create small anonymous functions. Since Lambda functions are anonymous, they are typically used for short term tasks where a function doesn't need to be reused elsewhere in the code. Lambda functions can only have one expression, which means you cannot include multiple statements or complex logic in them. This makes them less suitable for tasks that requires several steps or detailed operations where it's better to use regular function. But Lambda functions are especially helpful in functional programming because they can be used as arguments in functions like map, filter, and reduce. This makes it easy to write clear and concise code for transforming and filtering data without a lot of extra lines. So for now, that's all save in the next lesson. 25. Understanding Scope in Python: Welcome back, guys. In this lesson, we will explore what scope means in Python, and let's define right away that there is variable scope and scope resolution. Variable scope is a fundamental concept in programming that dictates where variable can be accessed and its lifespan in the program. Scope defines where a variable can be accessed. A variable declared within a specific scope is not visible or accessible outside that scope. For instance, a variable defined within a function cannot be accessed from outside that function. And we call this local scope. The lifetime of a variable is the duration for which it exists in memory. A variable remains in existence and can be accessed as long as it is within its defined scope. Once the program execution leaves the scope where the variable was defined, the variable typically becomes unavailable, freeing up memory. We have four types of scope, and in general, it has abbreviation LGB. It stands for local scope. Variables declared within a function or block are local to that function or block and can only be accessed there. Then we have enclosing scope in cases where a function is defined inside another function. The inner function can access variable from the outer function. Then we have global scope. Variables declared at the top level of a script or module can be accessed from any part of that model, including inside functions and built in scope. Certain names are predefined by the programming language and are always accessible, such as functions and constants like N or print in Python. By clearly defining where variables can be used, scope helps prevent situation where two variables with the same name might lead to confusion or errors in the program. Understanding the scope makes it easier to read and understand the code as developers can quickly determine where and how variables are being used. Let's move on to practice. Let's imagine I have two simple functions. I write the first function and here I defined variable A. Then I write the second function, and here I defined variable B. These two functions print their variables. If I call these two functions and then run the code, we will see these variables in the console. But what if I want to print the variable A in the second function? When I call the second function, it will raise a name error because the variable A is not defined in the scope of second function. I defined in the scope of the first function, so we cannot use it here. In a Python, when a variable is referenced, the interpreter searches for it in a specific order, starting from the most local scope and moving outward until it finds the variable. This order is often called the LEGB rule. In the second function, Python tries to print the variable A. However, A is not found in the local scope, so the Python moves to the global scope to search for A. But since A is not defined globally, Python raises a name error. Here we looked at local scope. Let's explore further. I'll rename the functions for better clarity and I will add an indentation to make the second function nested within the first and notice how the error warning underline on variable A disappeared. This is because now we have enclosing scope, and the inner function can have access to the A. If I run this code, it works. The variable A is defined in outer function. This means A is enclosing scope of inner function, making it accessible to inner function, even though A is not defined directly inside the inner function. Now let's consider global scope. Here I defined global variable A with the value 33. Then I created two functions, functions one and functions two that brnt this variable. Note that the initialization of variable A is not present within the function themselves, but in the print function, it's not underlined as an error. Now, I call these two functions and run this code. When function one is called, it prints the value of A, which is 33. When function two is called, it also prints the same value of A, which remains 33. That's what we have two identical variable here in console. But if I define A inside each function, each function will create its own local variable A. These local variables will shadow the global variable, and that means that the function now reference their own versions of A instead of global one. And if I run this code now, we will see totally different picture. The function one will print its local variable and it's 22, and the function two will print its local variable that is 11. But let me remove the local variable for function two. Now, if I run this code, the function two will print the A that equals to 33 because function one says only local A variable that equals to 22, but in function two, there is no local variable is defined. This means that when print is called inside function two, Bython follows its usual LEGB rule to resolve the variable name. Since there is no local A in function two, Python checks the global scope next, where it finds A equals to 33. In Python, the global keyword is used to specify that a variable inside a function should refer to a global variable instead of creating a local one. So if I here in the function one, write the global keyword, I will make this variable as a global. When I use the global inside a function, I tell Python to use the variable from the global scope and allow modifications to that global variable from within the function. So here in the function one, the variable A equals to 22 modifies the global A that was equals to 33. And if I run this code, we will see that output is 22 because in function two, we don't have local variable at all, and the function searches the variable in the global scope. But in function one, we modified the global variable A so that we have 22, two times. But modifying global variables directly within the function, it's generally considered poor practice in programming. A better approach is to pass variables as arguments to function and return any modified values. Understanding variable scope is essential for writing clear, maintainable and efficient code. In Python, the scope defines where variable can be accessed or modified with distinct levels such as local and closing global and built in. This hierarchy determines how Python searches for variables within function, nested functions and overall module. For now, that's all. See you in the next lesson. 26. Handling Errors with try-except in Python Functions: Welcome back guys. We have already encountered errors several times when running code, which were displayed in the console. In application development, it is important to handle such errors and not display them to the user. Instead of showing row error messages, developers should implement error handling mechanism to provide user friendly messages. This not only improves the user experience, but also protects sensitive information that might be exposed through the error message. Proper error handling can help maintain the stability of the application and guide users towards resolving issues without revealing technical details. And now we will learn about error handling the try and accept construct. Let's start from a simple example to better illustrate how this works. For example, I will try to divide by zero. I will write regular function that takes two numbers and performs division. Then I initialize the variable result, called the function, and eventually I print the result. If I run the code, I will get an error indicating that division by zero is not allowed. Now let's use the construct to handle this error. The dry block contains code that we want to try to execute. In my case, it will be division. Then I have block except. This block contains code that handles the error. This block will catch any exception that occurs in the trib block. Here, the function division attempts to divide A by B within a trib block. Then when I call division with three and zero, the function tries to execute three divide by zero. In pan dividing by zero raises zero division error. Since the division by zero results in an error, the program control immediately jumps to the except block. In my case, the except block doesn't specify any particular exception type, like, for example, zero division error. Instead, it catches all exception that might occur in the tri block. When the error occurs, the except block executes and prints the message error occurred cannot divide by zero. This is a user friendly message designed to inform the user that the operation could not be completed due to division by zero error. Let's run the code to verify this. In this case, we won't see the usual Python traceback error message in the console. Instead of the technical error information, the user sees a friendly message indicating what went wrong. I replace zero by one and run the code again, and I won't see anything because I forgot to print the result. That's much better. Now everything works. In general, we ask the program to try to execute this block of code. If something went wrong, we explain this for user, and Python exceptions are events that disrupt the normal flow of program's execution. Python provides a rich set of build in exception classes that represent various error conditions. By using specific exception classes, you can catch and handle errors effectively. For example, in my case, I can rewrite this code like this. Here I use zero division error. This pot of code allows me to store the exception instance in a variable E. This variable gives me access to details about the exceptions such as type and any associated error message. It can be useful for logging, debugging, or providing more informative response to the user. The zero division error exception is raised because dividing by zero is not allowed. By catching it with this block of code, I assign the actual exception to the E variable. Now E contains the error message that explains why the error occurred. But by specifying zero division error, we make it clear that we are only handling division by zero errors. This avoids accidentally catching other exceptions that might be raised like type error, name error, or any other error unrelated to division by zero. For instance, if I mistakenly pass a string instead of a number, Python would raise and type error. My block of code would ignore this and let the program fail. Because in this example, we should use type error in the except block. In this case, using type error in the except block is useful because it helps handle cases where one or both inputs to the function are of the wrong type. We also can use multiple except blocks to handle different types of exception in a single tri block. Let's rewrite this part of code. This is useful because different kinds of errors may require different handling by specifying multiple except blocks, each for a different exception class. We can handle each error type in a way that makes sense for that specific error. Here we handle the situations with zero division error and type error separately. This approach has several advantages. Each exception type gets its own specific handling, so the program can respond appropriately to different errors. Each block can print or log specific messages, making it clearer to understand what went wrong. And finally, by specifying exceptions, we ensure that only the relevant errors are caught and handled, leaving other potential errors unhandled, so they can be fixed or addressed separately. But that's not all. The complete error handling construct looks like this. We also have Ls and finally blocks Ls block runs, if no exceptions were raised in the tri Block. Finally, block that runs no matter what, whether an exception was raised or not, let's consider an example. So here I write L's block. It executes only if no exceptions were raised in the trib block. Here it simply prints division was successful to indicate that the operation succeeded. Then I add final block. It runs regardless of whether an exception occurred or not. It will print execution of the division function is complete to indicate the function's end. Note that the print statement in the s block is now a different color. This is because this block is supposed to execute only if no errors are raised. However, in the tri block above, we have a return statement. This means that if there are no errors, we will simply return the result immediately and the s block will never be executed. Therefore, we initialize the variable result here. Now that we have the complete construct, let's test it. The first time we have incompatible types. In this case, we got the message from the except block with type error, and we have message from final block. If I replace by zero, we got the message from the second exception block and from the final block. Let's test the valid input. I replace zero by one. In this case, no exceptions were raised in the trilog and the s block was executed, of course, final block. So in Python, there is a wide range of building exceptions that can be used to handle errors effectively. These exceptions cover a variety of errors types, including logical errors, runtime errors, and system related errors. Complete list of these building exceptions can be found in the official Pattern documentation. Let's summarize this construct briefly. Use Triblog to wrap code that may raise an exception. Utilize except block to catch specific exceptions and handle them appropriately. Use the Else block for code that should execute only if no exceptions occurred. Use final block for cleanup tasks. That should happen no matter if an error occurred or not. By understanding and utilizing this exception handling mechanism, you can write cleaner, more readable and maintainable code. Later in the section OP, we will learn how to create custom classes for error handling. For now, that's all. See you in the next lesson. 27. Introduction to Modules in Python: How to Work with Them and Use the Import Statement: Welcome back, guys. Let's explore what modules are, how to create them, and how to work with them. In Python modules are a way to organize and reuse code. A module is simply file containing Python definitions and statements like functions, classes or variables, which can be imported and used in other Python programs. Modules help in structuring your code and keeping it clean, maintainable, and modular. To use a module, you need to import it into your current Python script using the import statement. Let's see how it works with the example. We'll be working with three separate Python files. These files located in a single root directory called Project one. Let's create Project Spacewar. This will be a program designed to manage and display player statistics for a functional game called Space Wars. The project is divided into three modules, as I said before. First, gaming stats. This module will contain functions for calculating a player's score based on their achievements and in game score, and also will determine their rank. Then player Utils. This module provides utility functions for creating player profiles and formatting their game statistics in readable format. And the third file main dot pi. This is the main file that ties everything together. It creates a player profile, retrieves their statistics, and displays a formatted summary, including their rank and score. Here we will learn how to use Python modules, where each module is responsible for a specific part of the functionality and how to import and use functions and constants from other modules to build a cohesive application. Let's start from gaming stats. This module will contain two important functions, calculate score and get rung. The doc string explains what the function does. It calculates the player's score based on their achievements and in game score. We already know that doc strings are written inside triple quotes and are typically placed immediately after the function or class definition. So I create calculate score function. It takes two arguments, achievements and score and calculates the score by dividing achievements by the score. If the score is null, the player gets a bonus score based on achievements. And here I use the command bonus for zero scope. It's an example of a single line command in Python. Single line commands are used to explain specific line or section of code. It helps developers understand what that part of code is doing. This is especially useful when the logic might be complex, non obvious, or when you want to provide additional context for a particular operation. If score not equals to zero, we return calculation the player's score based on their achievements. The round is a built in function in Python. That rounds floating point number to the nearest integer. Then I create the second function, get Rank. This function will take an argument, score this argument, we will get from the calculate score function. I remind you that this function return this value. Here I also create doc string. The rank will range from beginner to professional. I'll add an emoji for a more visual example. The score is compared against a series of threshold, 200, 150, and 100. And depending on the value of the score, corresponding rank is returned. These rank are represented by string that include both the name of the rank and an emoji to visually represent the rank. Then I use the comment again to indicate that the following variables are constant that are defined in the module level. In Python, constants are usually written in per case. We already know it. Constants are values that are meant to remain unchanged through the execution of the program. The first constant represents the default value for a player's achievements. It's useful for initializing player profiles when no achievements have been earned yet. For example, when a new player starts the game, their achievements can be set to zero as straight point. The second constant represents the default score for a player. Similar to default achievements, this value initializes a player's score to zero when they start playing the game, assuming they haven't scored any points yet. The third constant stores the name of the game. We will reuse these constants across the project. The second module is responsible for creating player profiles and formatting player statistics. The first line of code, we use an import statement which is used to bring function variables or classes from one module into another so that they can be used in the current script. From as a keyword, used to specify the module from which you want to import. Gaming stats in the name of the module. I want to import from in my case, gaming statts dot pi is a Python file that contains functions and constants related to calculating scores and determining player ranks. Actually, the module gaming stats could contain various functions or constants. We are specifically importing only certain items from it. You can think of it as selecting what you need from the module instead of bringing the entire module into the current script. Here, I create function, create player profile. It will take two arguments name and age. Add dog string. Inside, I create a dictionary that store player information such as name, age, games played and achieve status. Name, this is the player's name, age, it's players age. Then we have games played zero. This sets the default value of how many games the player has played to zero, meaning the player is new or has not played any games yet. Is active true. This sets the player's status to active, meaning the players is currently active in the game. Then I create the function format player stats. It will take three arguments, name achievements and score. Create docstring. Docstring was for the function, and this in line command, I create to explain the purpose of the following lines of code where the function calculates score and get rung. This function formats the players statistics into human readable format, which can be displayed to the player used in reports or logged for tracking purposes. So I calculate score using the calculate score function imported from gaming stats. This function calculates the player's calculated score based on the logic defined in the gaming stats module. Here we use the arguments that we get. These arguments were defined in the calculate score function in the module gaming stats. Here we just use it. Then the Get rank function is called with the calculated score. It also imported from gaming stats module. Based on the score, the function return a rank, such as professional, expert or so on, depending on the thresholds defined in the gaming stats module. The function returns a formatted string containing the players statistics. Here we use formatted multiline string to create a readable well structured block of text. This means the string can span multiple lines, which is especially useful for formatting large block of text, such as report messages. We already know what F string formatting is. I won't repeat it. We worked with this. As a result, we have formatted message. And finally, mine module. First, I import modules and functions. First line, import specific functions, calculate score and get rung. By importing specific elements, you can use them directly in your code without needing to prefix them with the module name. Also I import constant game name also from gaming stats module. The second line imports the entire player Utils module. To use any function or variable from this module, you need to prefix it with the module name player Utils. When we use selective input as the first line of code, it saves typing and improves code readability by allowing direct access to functions or variables. It's useful when you only need specific part of a module. Full module input keeps the name space organized by requiring you to prefix the module name. It helps avoid name collisions if different modules have functions with the same names. So here I'm creating the main function. This function is the entry point of the script and coordinates the execution of various tasks. Create a command for the understanding. This line of code, create a dictionary that represents the user profile. Note that the dictionary is created using the create player profile function, which we can call from the imported module. Since we imported the entire module, we access this function using the module name followed by a dot and the function name. Here we pass name Nik and age 25. The profile includes default value like games played zero and is active true. Then I display welcome message using the constant game name imported from the gaming stats module. And here I just print the line of dashes to visually separate sections of the output. Here I define example values for the player's achievements and score. Achievements represents the number of achievements earned by the player and score represents a metric related to the player's performance, such as the number of games played or performance rating. Here I call the function format player stats. This function imported from the player Utils module takes the player's name, achievements and score as arguments. The reason we use the module name before the function is because we imported the entire module. As I said before, by importing the whole module, all the functions defined inside it need to be accessed using the module name followed by a dot. And here we get name accessing a value from a dictionary by writing player name, we are accessing the value associated with the key name in the dictionary, which in this case would be Nick. It uses calculate score and Gt run functions from gaming starts to generate formatted report, which it then returns as multiline string. And then I print the result. I output the formatted player statistics to the console, let's demonstrate direct usage of imported functions. Here I call the calculate score function from the giving stats module, which was imported at the beginning of the script. Here achievements, the number of achievements the player has earned. And score a performance metric, such as the number of games played or the row score. Inside the function calculate score, we have formula. This formula divides the number of achievements by the score and multiplies the result by 100. Then rounds the result to the nearest integer. This gives a calculate score based on the player's achievements and performance. Then I call the G Runk function that is used to determine the player's rank based on their calculated score. The rank is determined by the calculated score value passed into this function. Here the function checks the calculated score against various thresholds and assign rank. Then we print the player's rank using an F string. I'll remind you that these two functions mentioned above were imported from the gaming stats module separately. Therefore, when calling these functions, we don't use the module name with a dot. We can use this function directly without module prefix. This is one of the most important constructs in Python. It's used to control how Python screen behaves when executed directly or imported as a module. Name with double underscore is a building variable in Python. That gets assigned a value depending on how the script is being used. When I run a Python script directly, the varie of name is automatically set to main. If the script is being imported into another Python script, Import main and so on, like we did before, then value of name is set to the name of the module itself. In this case, main, the condition that we see in this line of code is a way to check whether the Python script is being executed directly or imported as a module. If the script is being executed directly, and I'm going to run it directly, name will be equal to main, so the condition becomes true. If the script is being imported as a module into another script, name will be the name of the script module. So the condition becomes false. I place the main execution code inside the structure. This ensures that the code is only run when the script is executed directly and not when it's imported elsewhere. It prevents unwanted code execution when the module is imported. The code will be executed only when the script is run directly. Guys, quick recap. A module in Python is a simple file with Pi extension that contains Python code, including functions, variables, classes. In our example, we have three modules, gaming stats, player Utils and main. Organizing code into modules helps keep your program organized. You can import an entire module using the Import module name syntax. Or alternatively, you can import specific functions or variables from a module using from module name, Import functions name. This allows you to either use the full module name or just the function name. But this is not all in pattern. You can import modules, functions, classes, or variables using an alias. This allows you to give a shorter or more convenient name to an important item, which can be particularly useful when working with long module names or if you need to avoid name conflicts. You can import entire modules, specific functions or variables using the as keyword to assign an alias. It's commonly used in large projects where modules or functions may have long names or when you want to keep your code clean and readable. Using aliases in imports is a standard practice in Python that improves the efficiency of coding and enhances code readability, especially when working with third party libraries or large codebases. So guys, see you in the next lesson. 28. Using Decorators as a Design Pattern in Python: How to Implement and Apply Them: Welcome back, guys. Let's look at decorators. In pattern, decorators are powerful and versatile tool that allows you to modify the behavior of a function or class. There are essentially functions that wrap another function to extend or rather its functionality without modifying other original functions code directly. Think of decorators as wrappers that you can place around a function to enhance its behavior. Let's see how it works on the example. A decorator is simply a function that takes another function as an argument and returns a new function. The first step, I define a decorator function that takes another function as an argument. Then I define an inner wrapper function inside the decorator function. Here I print message because wrapper will add some functionality and I'm going to show it. This message will indicate that we wrap the function, call the original function from within the wrapper, because it's wrapper, so I'm going to add some behavior after function is calling. I print another message that will indicate that function was calling and return wrapper function. Here's what's happening here. My decorator is a decorator function that takes a function funk as an argument, and inside my decorator, a new function wrapper is defined where I calls the original function funk and adds extra behavior, printing messages. Finally, my decorator returns the wrapper function. Your first simple decorator is ready. Let's use it. I'm going to apply this decorator to another function using the add syntax. Here I define the function, say hello, simple function that prints hello world. And I use my decorator. At the end, I'm calling the function. If I run this code, we got this message. We can see here that the message printed by our say hello functions is wrapped with two additional messages from the decorator. However, using the add syntax is optional. What the syntax does is simply a shortcut for the following. This code will perform the same way as we saw earlier. Python automatically passes say hello function to the M decorator function. The same say hello now refers to this new decorated function. And instead of using the add M decorator syntax, you explicitly pass the function to the decorator. This line takes the original halo function and passes it as an argument to the M decorator. My decorator returns a new decorated versions of the the hello function. And if I run this code, we can see the same result. But the add syntax is more concise and readable. So for the upcoming examples, I'll be using add syntax. I comment out previous code and start write new one. My first step I import time. This line inputs the time module, which provides various time related functions. In this case, I will use time method to measure the time before and after the function executes. This function timer will be decorator function. It takes a function funk as an argument. This is the function that we want to decorate or modify. The timer function returns a wrapper function that will replace funk when the decorator is applied. This wrapper function adds functionality. In this case, measuring the time taken by funk around the original function. Wrapper function takes any arguments and keyword arguments that funk would receive. This allows the decorator to work with any function signature. We define the start time, then we call the original function and defined end time, and then we print the elapsed time. Finally, it returns the result of the function, and at the end, the timer function returns the wrapper function. This is the key part of the decorator because rubber will now be called in place of the original funk. So let's test it. The line timer above slow function is a shorthand for applying the decorator. Slow function is a simple Python function that performs two tasks, simulates a delay using time slip and print a message indicating that the function has completed. The time slip function is part of Python built in time module. It pauses the execution of the program for the specified number of seconds. In this case, I choose 2 seconds. It means that when I call slow function, the program will slip or do nothing for 2 seconds before proceeding to the next line. When I call slow function, B then actually calls the wrapper function returned by the timer decorator. The start time is recorded before calling the original slow function. Then slow function is executed. This causes the program to sleep for 2 seconds. After slow function completes, the end time is recorded. The lapse time is calculated and printed showing how long slow function took to execute. So if I run this code, we will get the result. The first line function complete, it's indicating the function executed. The second line the time taken is approximately 2 seconds reflecting the sleep duration. Python also comes with several built in degrators in the OOP section, we will see Python's built in degrators in action. For now, that's all. See you in the next lecture. 29. Building a QR Code Generator for Social Media with Python: Now let's combine learning with practice and create something useful. Nowadays, almost everyone has a personal website or online presence. Whether it's Instagram or YouTube Channel or another platform, if you own a business, for instance, you might need a QR code to help people quickly access your resources. Most people are familiar with QR codes, but here's a quick reminder for those who aren't. QR code is a two dimensional barcode that can be scanned by smartphone or QScanner composed of black squares on white background, I stores various types of information such as text, links, phone numbers, and more. QR codes are popular for their convenience and versatility. Whether you are sharing contact details or product information, they offer a quick solution with additional marketing and promotional potential. Let's get started. I've created a file where using Python, we will generate a QR code that links to my digital program live channel. You can use this for any resource. Your website, specific page, whether you choose, will be using the Python library QR code, which is a powerful tool for generating QR codes directly in Python file. And first, we will install the library using the Command, PIP install QR code, and then input it. Here we have the message. There was an error checking the latest version of PIP. It usually appears when PIP Python's package installer tries to verify if a newer version of itself is available. This error is not critical and won't prevent you from using PIP to install or manage packages. It simply means that PIP couldn't check for its own latest version, but it will still function normally to install update or uninstall packages as needed. There doesn't have impact on my workflow, so for now, I leave it as it is. Now let's write a function called Generate QR code. This function will take arguments such as data, it's our content. File name and error correction. F two arguments are positional and the third one is keyword argument. The data parameter will be the link of resource we want to encode, while error correction will define the error correction level, which affects the QR codes resilience to damage. I will make it a bit readable. When each parameter on its own line, it makes it easier to scan and identify individual arguments. The QR code library in Python includes error correction settings, which manages how much data in the QR code can be recovered if part of it is damaged or obscured. It offers four levels. Low, recovers up to 7% of data, medium, up to 15%, quartile and high. We will use the low error correction level capable to recovering up to 7% of damaged data. For now, it's enough. Next, I will set the box size. This parameter sets the size in pixels of each individual square in the QR code grid. A larger box size makes each square in the QR code bigger, resulting in an overall larger image, let it be ten and this parameter specifies the thickness of the white border around the QR code measured in boxes. By default, a border of four is recommended to ensure scanners can easily distinguish the QR code from its background. Then inside the function, I initialized the variable QR and create a QR code object, then add our data in the QR code library, QR code is a core class used to create and customize QR codes. This class provides a structure and functionality needed to define a QR codes contents, design, and error correction level. When creating the QR code object, we pass error correction, box size, and border as Keyword arguments. Since these arguments are provided in the function call, they'll be used in QR code object with the values we set. Don't worry. We will cover what classes are in all P section. For now, the main thing is to understand how functions and libraries work. Then I call Add Data function. This function allows you to add the actual content or data like URL or text that you want to encode in QR code. You can add multiple pieces of data with this function and the QR code will store all of it. Then I use M function. This function generates the QR code image based on the data provided. When parameter fit equals to true, the QR code automatically adjusts its size to feed the data, ensuring that all information is stored without overflow or truncation. Then make image function generates a QR code image object based on the data and settings previously defined in the QR code object. Parameters that we have here, the first one fill color. I set it black. It sets the color of the QR code itself, the squares. The second parameter, back color, I set it white. It sets the background color behind the QR code squares. Eventually, we save it. The filename argument specifies the file paths and name, including the extension. We already covered this construction of code in previous lessons. This line of code ensures that the code runs only when executed directly, not if imported as a module. Next, we will call our function and pass all the information. The function call here generates a QR code linking to a specific URL. In my as it's my channel link. And I save it as QR code PNG. So here we have the code that demonstrates functional programming principles by encapsulating the QR code generating process within a single function, generate QR code. By putting all the steps within generate QR code functions, the code is reusable and modular. Let's check if it works. Now, if I run this code, I will have a file. If I open it, will see our QR code. Let's test it. While it works. Try creating your own QR code. You can use any link you'd like and get some practice. You can try generating a QR code for your Instagram profile, for example, for now, that's all. See you in the next lesson. 30. Introduction to Object-Oriented Programming (OOP) in Python: Classes and Objects: Welcome back, guys. Congratulations. We are moving on to the most important part of learning Python object oriented programming or P. This powerful concept will help you write cleaner, more organized code and make your program more flexible. Enjoy the learning process. In Python, everything is an object. This means that every piece of data in Python, whether it's a number, a string, a list, or even a function, it's treated as an object. In OP, a program is made up of a collection of objects that interact with one another to accomplish tasks. Object oriented programming offers several advantages. One key benefit is that OOP helps organize code better and encapsulate it. By grouping related data and methods together within an object, your code becomes more modular and easier to maintain. Another advantage of OOP is that it enables the creation of reusable code. By defining classes that can be instantiated into objects, you can create code that can be used in different parts of a program or across multiple programs. In Python, all P is implemented through classes. Classes define the properties and behaviors of an object. While objects are instances of classes that contain data and methods. We can think of a class as a blueprint for creating objects. They define the structure and behavior attributes and methods that objects created from them will have. Method, on the other hand, are functions defined within a class that describes specific actions or behaviors this object can perform. So class defines the blueprint, while methods define actions for object created from that blueprint, you can think of an object as a real world entity that has characteristics and actions. Let's take a simple example to illustrate this concept. Let's create a simple class called dog. It will be the blueprint for the instances. I'm going to create later. Here I use the it method. It's a special function called a constructor. It automatically runs each time you create a new object or instance of the class. Here I pass the parameters name and age. Self a refers to the specific object being created, so you can assign data specifically to that object. These two lines of code, self name equals to name and self age equals to age. Create two attributes name and age and set their values based on the provided arguments. This allows each instance of the class to have its own name and age values. You will see it in a minute. Next, I create the function bark. This function in the dog class is a method that defines a behavior for instances of the dog class. This is a simple function that returns. Now we have our first simple class. This is our blueprint. Next, I'm going to create instance from this blueprint. I initialized variable my dog. Then I call my newly created class dog with the following parameters. It will be Lu and three years. When I call this line, the following happens. Python allocates memory for a new dog object, and the net method, as I said before, constructor is executed. Here we have the self name that will be Lu and self age that will be three years. The self parameter allows the method to refer to the instance that is being created, making it possible to set its attributes. The init method runs only once during the creation of an object. This means that each time you create a new instance of dog, the it method runs to initialize that specific instance with the provided attributes. In my case, the dog's name and age, but it could be different attributes. So here, newly created dog instance is assigned to the variable my dog. When I created instance of dog and stored it in my dog variable, I can call its method. This line of code calls the bark method, which returns the string and prints it. Let's run the code and see the result. When you want to call a method on an instance, you use dot notation. This means that to access any method or attribute of the instance, you always use the instance name followed by a dot and then the method or attribute name. I also want to draw your attention to the fact that when you initialize an instance, you must provide all the required arguments specified in the constructor. Otherwise, you encounter an error. Let's consider class attributes. These attributes can be shared across all instances of a class and can be accessed using the class name or through an instance. In my Dog class example, the class attribute species holds the value canine. I will add the method info. Where I'm going to use all three attributes, one of them will be class attribute, and another two will be our name and age. Just to show you what's the difference between these attributes. The self parameter allows the method to access the specific instance on which it was called, making it possible to retrieve the instances attributes like name and age. And here I use self species. In this case, it refers to the string keine. Since species is defined as a class attribute, every instance of dog can access it through self species, even though it's defined at the class level. So what I'm going to do next, I create two instances of the dog class, let it be a little low and rick. Then I print the info where we use all parameters. I print info about u and info about Rik. Almost forgot about second attribute that we defined in constructor. Now, if I run this code, we will get two messages for each instance. And as we can see, we have different names, different years, but the same species. That's because species is a class attribute. We already saw that we can call the method using dot notation. In Python, we can also access attributes on an object, both instance attributes and class attributes, and we can do this also using dot notation. This means we write the name of the instance or the class followed by a dot and then the name of the attribute. Look at this. I called the class attribute on two instances. And if I run this code, we can see that I've got species twice, and twice it was nine. This is because species is the class attribute, and all dog instances share this value. But the attribute name and age are created inside the init method using self name and self age. These are instance attributes, meaning they are specific for each instance of the dog. Accessing name through instances my dog and my dog two will give us two different names. But it's important to understand the difference between class attributes and instance attributes. And the main difference class attributes are accessible on both the class and its instances, like we saw before, here I call class attribute on the class and everything works. Instance attributes are only accessible through individual instances as they represent data unique to each instance, trying to access instance specific attributes like name or each director on the class will return an error because these attributes don't exist at the class level. They are only created when a specific instance is initialized. So we've now covered what glasses, Wan instances, and we've been introduced to class attributes and instance attributes. We've also considered the NIT method. The NIT method is a special method, sometimes called a magic method that is automatically called when a new instance of a glass is created. Similarly, before an object is destroyed, a destructor is called, which in Python is named Dell. This is another one, magic method. But since the interpreter automatically manages the release of resources used by the object, having a destructor is not very important. Therefore, I will not delve into this method right now. We won't focus on magic methods in detail right now. We'll cover them more deeply later. For now it's essential to understand that init is the constructor method used to initialize each instance with the unique values or configurations where the object is thus created. When you create an instance of a glass, Python automatically looks for the init method in the class. This method usually takes self as its parameter, which represents the instance being created along with other parameters to set up the instance with specific data. The last thing I will mention in this lesson is that in Python, class names are conventionally written in a capitalized first letter. This style is known as camel case. For example, class names might be written as dog, person or animal species. This capitalization helps distinguish class names from other variables or functions, which typically start with a lowercase letter. In the next lesson, we will explore the different types of methods in classes. So for now, that's all see you in the next lesson. 31. OOP in Python: Working with Class, Static, and Instance Methods: Welcome back guys. Let's continue. In object oriented programming, especially in language like Python, classes can contain different types of methods, instance methods, class methods, and static methods. Let's start from instance methods. These are the most common type of methods in classes. They operate on an instance of the class, an object and can access and modify the instances attributes. We saw this in the previous lesson. We had bark and info methods. They have access to the instance attributes defined in the it method. And the first parameter of an instance method is typically named self, which refers to the instance of the class calling the method. Here I can say that bark not an ideal example for instance method because we don't use here the attributes. I will use the info to show you how it works. An instance method in Python can access class data, class level attributes, but it is primarily designed to be called on an instance of the class, not the class itself. I will remove the call to the bark method. And run the code. I'm sorry, I forgot about the second attribute and it's important. It's age. Now it works. As I said before, this method has access to the class attributes. But if I now call this method on the class, I will get an error. Yes, instance methods can access class data, but they are meant to be called on instance, not the class itself. You actually can technically call it on the class. I'll show you how. However, it's not recommended since it's not typical usage pattern for instance methods. Here I call the in, for instance, method directly on the class dog, but I manually passing my dog an instance of dog as the first argument self. It leads to confusion because instance methods are designed to operate on instances and calling them on the class directly doesn't clearly indicate this behavior. So I don't recommend it. I just showed you how it can be. Now that we've covered this, let's move on to the class methods. Class methods are methods that belong to the class itself rather than any particular instance. The first parameter of a class method is typically named CLS, which refers to the class itself. They can access and modify class attributes, variables defined in the class level. They are marked with a decorator class method. Decorator in Python is a special function that modifies the behavior of another function or method. In this case, decorator is used to define a class method. When you use class method, it tells Python that the method should receive the class itself, CLS as its first argument. Instead of an instance of the class self, this allows the method to access and modify class level data, but not instance specific data. Here's how we can call this method. By using the class name directly, we're calling a method at the class level. Then the dot operator, it used to access attributes or methods within a class or instance, and then the class method get pieces we defined in the dot class. At the end, the parenthesis that we are used to call the method. Without parenthesis, this line of code would just be reference to the method itself, but not actually call it. So when I run this code, Biton locates the dog class. It uses the Get species method in dog class because class method allows it to be called on the class itself. Get species is executed with the dog class as its argument CLS. The method performs its function, which typically involves returning a class level attribute. In my case, it's species, and then we print the result. So we call the class method on the dog class two or three with shared class level attribute. Let's go on with static method. These are methods that don't modify the glass or instance state. They are defined using the static method decorator. First, I return the bark method that we had before. In this example, I can convert the bark method into a static method by adding the static method decorator. This is an excellent example because bark method doesn't need to access or modify any instance specific data or any class specific data. So static methods are ideal for functions that perform tasks independently of the classes state. It can be something like utility or helper functions. By marking bark method with static method decorator, I'm indicating that this method doesn't require reference to an instance or the class itself. So I don't have here self or CLS arguments. This gives Bark simple and communicates to other developers that it's a standalone function, not dependent on any data inside the class or instances. I can call Bark directly on the dog class. And also I can still call bark from an instance of dog. First, I create instance of dog class, and then I call the static method on this instance. Regardless of how we call a static method, we get the same result. Let's summarize, instance methods are the most common type of the methods in object oriented programming. They operate on an instance of the class and have access to the instances attributes and other instance methods. The first parameter of an instance method is typically self. This refers to the instance of the class that the method is called on, allowing the method to access and modify the instances attributes. In Python, the self parameter is not a keyword and can technically be replaced with any name you prefer. However, it's widely accepted convention to use self at the name for the first parameter of instance method. This is because it helps improve the readability of the code and makes it clear that the method operates on an instance of the class. Then we have class methods. These are methods that are bound to the class itself rather than an instance. They are used for operations that affect the class. The first parameter of a class method is typically CLS, which refers to the class itself. This allows the method to modify class level attributes. In class methods, CLS is a convention, much like self is used in instance methods. It refers to the class itself, as using self is important for clarity and consistency when dealing with instance data. Sticking to the CLS convention in class methods is considered best practice. These methods are useful when you need to operate or modify data that is shared among all instances of the class. Class methods can be called both on the class itself and on an instance. But this behavior is not usually necessary. It's possible because class methods are bound to the class and not to an instance, meaning that they can be accessed from both the class and the instance. Sometimes it might be more convenient to call a class method on an instance, especially if you are already working with an instance and want to access a class level method without referring to the class name. But typically class methods are meant to be called on the class itself, not on an instance. Then we have static methods. These are methods that don't depend on class or instance data. They are completely independent and don't have access to instance or class. We can use it for utility functions that are related to the class but don't depend on its state. Static methods don't take CLS or self as their first argument. They are self contained. We can call these methods both on the class and on an instance. Though typically they are called on the class directly. Each method type serves a specific purpose. Instance methods for interacting with object data, class methods for class level data, and static methods for utility functions that don't depend on class or instance attributes. By understanding these differences, you can better structure your classes and methods. So for today that's all, see you in the next lesson. 32. OOP in Python: Single and Multiple Inheritance Explained: Welcome back, guys. We have studied what a class is, what a class object is, and the type of methods in classes. Now it's time to get acquainted with one of the main concept of OOP inheritance. When explaining inheritance in Python, it's helpful to start with the foundational concept that all classes in Python inherit from a build in root class called object. Understanding this helps clarify the structure of class inheritance in Python, especially since all classes, even those that don't explicitly inherit from any other classes, still derive from object. In Python, object is the most fundamental class from which all other classes implicitly inherit. This means that every class in Python is a subclass of object, making object the root of Python's class hierarchy. Since all classes ultimately derive from object, the object class is always at the top of any inheritance hierarchy. Before I show and prove this to you, let's cover a bit of history. Python, there was a significant shift in the design of classes with the release of Python 2.2 when the concept of new style classes was introduced. Before Python 2.2, Python only had what were called style classes. They were simpler and they did not inherit from the root object class. Instead, they formed their own separate class hierarchy. All style classes are no longer used at all in modern Python. Python three, the concept of old style classes was completely removed. So all classes are now new style by default. Python 2.2 introduced new style classes as an improvement over old style classes. The biggest change was that all new style classes inherit from object, creating a unified object model where everything, both user defined classes and build in types, was part of the same inheritance hierarchy. When Python three was released, the designers of Python decided to make all new style classes by default. This means that in Python three, the old style class concept was completely removed. Any class defined in Python three inherits from object automatically, even if it doesn't explicitly declare so now writing class I class and class I class inherited from object are equivalent in Python three, both creating new style classes. When creating classes, we can explicitly inherit from object to clarify that our class is derived from the root class. This is often done to ensure compatibility with both Python two and Python three. You may encounter this need when working on a legacy project. If you don't know what it is, legacy project and programming refers to an older software system or applications that's still in use, often without data technology or code that requires maintenance or updates. Let's start from the single inheritance. I'll create two classes, the animal and the dog. The animal will have one instance method, just eat. The dog class inherits from the animal class. And we have also one instance method bark. Single inheritance occurs when a subclass inherits from only one superclass. We call parent class as a superclass. In our case, it's animal. The animal class can be as a general template or base class, and in our example, it represents any kind of animal that can eat. We can imagine other animals like cats, lions that could inherit this behavior. Then we defined dog class that inherits from animal class. This means that dog will automatically have all properties and methods of animal class. Inside the dog class, there is a new method, bark. This method prints barking by inheriting from animal. Dog not only inherits the ability to eat, but it can also have its own behavior such as barking. Here we can see method inheritance and extension. Dog gets the eat method from animal class, and additionally, dog defines a unique method bark, that's specific to dogs. Now, I create an instance of the dog class because dog inherits from animal. Dog is also considered as an instance of animal, which means it can access all methods and attributes of animal, as well as those defined specifically in dog. So here I'm calling the inherit eat method. This line of code calls the eat method. Since dog is an instance of dog and dog inherits from animal, Python looks for the eat method in dog first. Then Python moves up the inheritance chain to the parent class animal where it finds and executes it. And here I call the bark method, which is defined directly in the dog class. Since bark method is unique to the dog class, Python doesn't need to check parent class animal at all for this method. It directly calls bark from dog, and here we have the result. Here we defined the method bark in the subclass. This method is specific to the dog class and doesn't exist in the parent class animal. So here we call the inherited It method from animal class, and here we call the bark method. That was defined specifically in the dog class. Let's proceed with multiple inheritance. Multiple inheritance is a feature in object oriented programming. That allows a class to inherit from more than one parent class. In Python language, multiple inheritance is supported, which means you can create a class that inherit attributes and methods from multiple base classes. I will extend the animal and dog classes by introducing a new class called PAT, which represents behavior specific to pets like playing. Then we will use multiple inheritance to create a dog class. That inherits from both animal and pet. So now what we have, we have the animal class that has one method it, which prints eating when called. This class represents general behavior common to all animals, such as the ability to eat. Then we have pet class that has one method play, which prints playing. This class represents behavior specific to pets, such as the ability to play. We have the dog class. This class inherits from both animal and pet. This allows dog to inherit methods from both animal and pet, so it will have access to it from animal and play from pet. Additionally, dog defines a unique method bark, which is specific to dogs and not present in either parent class. Then we created instance of the dog class. Since dog inherits from both animal and pet, the dog instance will have access to methods from both classes as well as its own method bark. Here we already have the method E, we called it, then we called method bark, and now I just add method play. The method from the PED class. As I said before, since dog also inherits from PED, it can use this method too. And if I run this code, we will clearly see the result. In multiple inheritance, Python needs a way to determine the order in which it searches parent classes for methods. This order is known as method resolution order, MRO. The MRO defines the sequence in which Python will look up methods and attributes. In this example, the dog class will first look in its own class for a method, then in animal, and finally in PAT. Following the order, they are listed in the Dog class declaration. You can see MRO for a class using the MRO attribute or MRO method like this, I'll show you. Here we see the dog. First, the animal, the second, the third, we see PAD glass, and at the end class object. Why order matters? If both animal and pet define a method with the same name, let it be sound. Python would use the MRO to determine which one to call. Python uses C three linearization, also called the C three super class linearization algorithm to establish the order for MRO. So here I defined two identical methods, sound, and here I call this method. The MRO for dog in this case will be dog, animal, pet, and object. So Python finds and executes sound in animal first. For now, that's all. See you in the next lesson. 33. OOP in Python: Multilevel and Hierarchical Inheritance: Welcome back guys. In the previous lesson, we learned about single inheritance and multiple inheritance. We also learned about method resolution order, which determines the order in which base classes are searched when executing a method. Now I'm going to explain what multi level inheritance is. Multilevel inheritance is a type of inheritance in which a class is derived from another class, which is already derived from some other class. This forms a chain of inheritance where each subsequent class inherits from its predecessor. For example, animal is the base class. Mammal inherits from animal, making it a derived class. Dog inherits from mammal, which makes it a subclass of mammal and indirectly a subclass of animal. Let's now write this out in the form of code. Here I extend the animal and dog classes and add class mammal. So here I have animal as the base class. It has single method ET. Any class that inherits from animal will have access to this ET method. Then I created mammal is a subclass that inherits from animal. By inheriting from animal, the mammal class gets all functionality of animal, which means mammal has access to the eat method. Additionally, mammal defines a new method called breath, which outputs breathing. And eventually, we have dog is a class that inherits from mammal. Since mammal inherits from animal and dog inherits from mammal, the dog us inherits all the methods from both mammal and animal, and dog has its own method called bark. So here I created an instance of dog. The dog object has access to all the methods defined in its class hierarchy, which includes methods from dog, mammal, and animal. The first I call eat method, Python checks if dog has an eat method. If not found, it looks in mammal, the parent class of dog. If not found there either, it looks in animal, the parent class of mammal. The Mod is found in animal, so eating is printed. Next, I call breath method. Python checks if dog has a breath method. If not found, it looks in mammal. The method is found. Mammal, so breathing also is printed and with bark method, very easy. The method is defined directly in the dog class. Here we have barking. We can see the method resolution order by using the Amro method. In the previous explanation, we used MRO to check the method resolution order of the Dog class using MRO method with underscores. Both approaches provide the same MRO information, but in different data structures. This can affect how you work with the results such as iterating over them or modifying them because the first one returns a couple of classes. And in our case right now, it returns a list of classes. So it depends what you are going to do next. Let's return to the multi level inheritance. And it will be good to use. Multi level inheritance allow you to logically group related behaviors. For example, all animals can eat, but only mammals can breathe. You can easily extend the hierarchy by adding more subclasses. For instance, we could add a cat class that also inherits from mammal but has its own specific behaviors. Multi level inheritance allows you to build a chain of classes where each class inherits from its predecessor. It's useful for creating a structured and reusable class hierarchy, but should be used with care to avoid complexity. Another type of inheritance I would like to introduce you is hierarchical inheritance. Hierarchical inheritance is a type of inheritance in which multiple subclasses inherit from a single parent class. This means that one base class parent can have multiple derived classes children that inherit its attributes and methods. For example, here, the animal in the base class parent class, cat and dog are subclasses. Both cat and dog share the properties of animal but also have their own unique methods. Let's take a look at this in practice. So here I removed the class mammal and add the cat class. Animal will be the parent class for both cat class and dog class. Cat is derived class that inherits from animal. This means that cat automatically has access to all the methods defined in animal like eat method. Additionally, cat defines its own method, Mo. In dog class, I replace mammal to animal because dog is another derived class that inherits from animal. Like cat, dog class also inherits the eat method from animal, and also additionally dog defines its own unique method bark. Then I create the instance of the dog class. The dog object can call eat method inherited from animal, and it can also call its own method bark. Then I create an instance of the cat class and the cat object can also call the method ID that was inherited from animal, and it can also call its own method Mu. As we can see, both cat and dog classes inherit the eat method from the animal class. This allows for d reuse. The eat method is defined only once in the animal class, but can be used by any subclass. While both cat and dog share the eat method, each class also has its own unique method, mile and bark. This allows each subclass to have specific behaviors in addition to the shared functionality. This structure promotes a clear hierarchy where multiple subclasses can extend the functionality of a single base class. This makes it easier to manage related classes that share some common behavior, but also have their own distinct behaviors. Common methods and attributes can be defined in the base class and re used by all subclasses. This reduces code duplication and enhances maintainability. Hierarchical inheritance allows you to logically groups classes that share common behavior. But we should remember that subclasses are tightly coupled with the base class. If the base class animals changes, all subclasses, cat, dog, may be affected. If a subclass needs to override a method from the base class, it can lead to confusion, especially if not documented properly. Here we will continue with method of writing, what it is and how we can do this. See you in the next lesson. 34. OOP in Python: Composition: Welcome back, guys. Now let's talk about composition and OP. In object oriented programming, Op composition is a design principle where one class is composed of the one or more object from other classes. This achieved by including instances of one class within another class as its attributes rather than using inheritance. When we learned inheritance, we clearly saw that these models is a relationship. For example, a dog is an animal. It allows one class to inherit attributes and methods from another class. In composition, we have models has a relationship. For example, a car has an engine. Instead of inheriting from another class, the main class contains an instance of another class. Let's look at this using the code from the previous lesson. I slightly rewrite my original example to use composition instead of inheritance. The animal class remains the same as before with a name attribute and it method. Instead of inheriting from animal, the dog class now contains an instance of the animal class as an attribute, self animal. Now self animal is an attribute of the dog class. This attribute is assigned an instance of the animal class. When creating this instance, we pass name as an argument to the animals classes init method because the animal class expects a name parameter when it's initialized. This means that when we create a new animal instance inside the dog class, we must provide a name value, which will be used to set the self name attribute of that animal instance. Because the dog class creates an animal instance during its own initialization, the dog class must also accept a name parameter. This is why dog classes. Method looks like this. Here we add name. When you create a dog object, you now need to provide a name, an age, and a breed. The dog class has its own attributes like age and breed in addition to the animal attribute. In the dog class, we have an attribute self animal that holds an instance of the animal class. Self animal name accesses the name attribute of the animal instance contained within dog object. We use it in formatted string to produce a personalized message. By calling self animal eat, we are delegating the eating behavior to the animal instance contained within the dog object. When I create an instance of dog, I pass in the name, age, and breed. The name is used to create an animal object, which the dog class uses internally. So after all of this, what benefits did we gain? Changes to the animal class are less likely to impact the dog class. You can easily swap out or modify the behavior of the animal class without affecting the dog class. For example, you can replace animal with another class or add more functionality without changing the dog class. By using composition, you're following the single responsibility principle. The single responsibility principle states that a class should have only one responsibility or reason to change. This means each class should focus on a single task or functionality, making the code easier to understand, maintain, and extend. The animal class is responsible for general animal behaviors, while the dog class handles dog specific logic. With inheritance, the dog class was tightly coupled to the animal class. If the animal class changes, it may break the dog class. We also had less flexibility. It's harder to change or extend behavior, especially if you have a deep inheritance hierarchy. I showed you an example of composition by refactoring the animal and dog code so that you could better understand the difference between the approaches. However, the example with animal and dog is not the best fit because the relationship between a dog and animal is more suited for inheritance. Composition is when we build an object from different components. It's like a car made up of various parts. Now imagine that you need to write code from scratch, where you would apply composition. Let's create a very simple example of composition using a car and engine scenario. In this example, the car class will use an instance of the engine class. The engine class has a type attribute and a start method to print a message when the engine starts. This class is independent and can be reused in other contexts. Then we have car class that has a model attribute and an engine attribute. Instead of inheriting from engine, it contains an instance of engine. The car class has its own start method. I created an instance of the engine class. Then I create a car object with the model Mustang and the previously created engine object, and then I call start method. This method called on the car object. If I run this code, I will get the message starting the Mustang car. For now, in the car class, we have self engine attribute, but it's not just attribute. It's an instance variable. That was initialized when a car object was created. This self engine holds a reference to an engine object that was passed as an argument to the car constructor. When we create an instance of the car class, we provide an engine object to the engine parameter, and this engine object is then assigned to the self engine attribute of the car instance. As a result, self engine refers to the engine object that was passed when car was created. After the engine object is assigned to the self engine attribute, the line self engine start calls the start method of the engine object stored in the self engine. This is an example of delegation, where a car class delegates the task of starting the engine to the engine class. And here we see that after the engines start method is executed, this line is executed in the car class itself. It prints a message indicating that the car is starting, including the model of the car. By passing the engine as a parameter during the creation of the car object, you gain flexibility in how and when the engine object is created. You might want to create a custom engine or modify the engine before passing it to the car. For example, you could add extra configuration to the engine before using it in the car. If the creation of the engine is done separately, you can have more control over its life cycle. For instance, you could decide to reuse the same engine across multiple cars without duplicating the creation logic. Let's summarize and identify all advantages and disadvantages of both approaches inheritance and composition. Let's start from inheritance. Inheritance models is a relationship, meaning the child class is a type of the parent class. For example, if you have a class animal and a class dog that inherits from animal, then dog is an animal. This implies that the subclass should be able to do everything the parent class can do plus possibly more. It's a hierarchical relationship where the subclass is a specialized version of the parent class. Composition models has a relationship, meaning that one class has another class as part of its structure. For example, car has an engine. The car isn't the type of engine, but rather uses an engine as one of its components. This means that the composed object like the engine in a car is just a part of the larger hole and can be replaced or changed independently of the main class. Inheritance is less flexible because it tightly couples the child class to the parent class. Any change in the parent class can ripple through all subclasses, making it harder to modify or extend your code without affecting existing functionality. For example, if I change a method in the animal class, it might break or change the behavior of the dog class, especially if the method is overridden. Composition is more flexible because it loosely couples the components. The main class can contain and use instances of other classes without being dependent on their internal implementation. This means you can easily change or replace parts of your system like switching out an engine object in a car class without affecting other overall behavior in your class. Inheritance allows code reuse by subclassing, where the child class inherits methods and attributes from the parent class. This can be efficient, but can also lead to issue if the hierarchy gets too deep or if you inherit unwanted behaviors. For example, if a subclass only needs a few methods from the parent class, but inherits everything, it may lead to unnecessary complexity. Composition promotes code reuse through object delegation, meaning that a class can delegate specific tasks to its component object. This allows you to combine functionalities from different classes without inheriting everything. This way, you can mix and match different components to create new behaviors, making your system more modular and reusable. When we use inheritance, changes in the parent class have a broad impact on all subclasses. If you modify method in the parent class, it affects every subclass that inherits that method, which can introduce bugs or unexpected behavior. In composition, changes are more isolated. Since classes are independent and communicate via their public interfaces, modifying one components does not directly affect others. So inheritance best used when you have a clear hierarchical relationship between classes. Like bird is an animal or manager is an employee. It's suitable when you want to take advantage of polymorphism, like in cases where subclasses share a lot of behavior with their parent class, but may also extend or override some methods. Composition ideal for cases where you want to build complex behaviors by combining simple independent objects. For example, a robot that has multiple interchangeable parts like battery or arms. It's great for scenarios where you want to follow the single responsibility principle and keep your system flexible, modular, and maintainable. I hope I have explained the difference in detail. For now, that's all, see you in the next lesson. 35. OOP in Python: Polymorphism: Welcome back, guys. Let's get acquainted with the concept of polymorphism. Polymorphism is a fundamental concept in object oriented programming that allows objects of different classes to be treated as objects of common superclass. It literally means many forms, and in programming, it refers to the ability to use a single interface or method to represent different types or behaviors. In Python, polymorphism is implemented through method overwriting and method over lodging. However, Python doesn't support method overloading directly like some other languages such as Java, but polymorphism can still be achieved through dynamic typing, inheritance, and method of writing. We already know that method of writing occurs when a subclass provides its own implementation of a method that is already defined in its superclass. In Python, the subclass method replaces the parent class method, but we can still call the parent class method using super function. Let's break this down with a simple example. So I start by defining a base class called animal. Inside the animal class, I define a method sound. When this method called, it prints the string some generic animal sound. This method is intended to be overridden by subclasses to provide specific sounds for different types of animals. Then I create dog class and it inherits from the animal class. In this subclass, the sound method is overridden to print sound, which is specific for the dog. When we create an instance of the dog class and called the sound method, it will print a woof instead of generic animal sound defined in the animal class. Then I define the cat class, which also inherits from the animal class. And here we also have sound method. Similarly to the dog class, the cat class overrides the sound method, but this time to print mio. I create a list called animals, which contains instances of three different classes, dog, cat, and animal. Dog creates an instance of the dog class, where sound method prints wolf. Cat creates an instance of the cat class, where sound method prints Mo and the animal creates an instance of the animal class where sound method prints some generic animal sound. I look through the list of animals, and for each object animal, I call the sound method. Yes, we can do like this. When I call animal sound, Python will dynamically determine the actual type of the object, dog, cat, or animal at run time, and invoke the corresponding method. This is polymorphism or method of riding, where the method sound behaves differently based on the object type, even though it's called in the same way for all objects, first iteration, and we have dog instance. The first object in the list is an instance of the dog class. The first iteration, we have dog instance. The sound method of dog is called, so it prints woof. The second object in the list is an instance of the cat class. In the second iteration, we have cat instance. The sound method of cat prints mile. And the third object is an instance of the animal class. So in the third iteration, the sound method of animal is called, so it prints some generic animal sound. Polymorphism allows the same method sound to behave differently depending on the type of the object. In this case, even though we are calling the same method sound for all objects in the list, the actual behavior that gets printed depends on the specific class of each object, dog, cat, or animal. Here we have the dog and cat classes that inherit from the animal class, which means they inherit its method, sound. However, they override the sound method to provide specific implementation. The sound method is called on the same way on all objects. But the actual method that gets executed depends on the type of the object, which determine at runtime. The sound method gives different results based on the type of object calling it. This makes the code more flexible and reusable because different classes can use the same method name but have their own unique behavior. Polymorphism helps make Python code more flexible, reusable, and easier to maintain, as it allows the same method to handle different types of objects in a clean and efficient way. For now, that's all. See you in the next lesson. 36. OOP in Python: Encapsulation: Welcome back guys. Let's dive into the concept of encapsulation. Encapsulation is one of the fundamental principles of object oriented programming, along with inheritance, polymorphism, and abstraction. In general, encapsulation means combining data like variables and the methods like functions that works on that data into one unit which is called a class, and it also restricts direct access to some of the objects components, which can prevent the accident modification of data. This is achieved through access modifiers that control the visibility of the class members. In Python, encapsulation is done using classes. You can control access to the data by using different access levels, public, protected and private, public members. These are accessible from anywhere, both inside and outside the class. Then we have protected members. These are indicated by single underscore prefix and they're meant to be accessed only within the class and its subclasses and private members. These are indicated by double underscore prefix and are not accessible directly from outside the class. So let's dive into this concept with detailed explanations. By default, all members attributes and methods in Python class are public, which means they can be accessed from outside the class. For example, here I define a new class named car. This class has a constructor method that's automatically called when an instance of the class is created. Brand and model are parameters that we pass when creating a new car object. Self brand and self model are public attributes of the class. Python, as I said before, all attributes are public by default, which means they can be accessed and modified from outside the class. The values passed as arguments brand and model are assigned to these attributes. For example, if we create the car object with car Toyota Camry, then self brand will be Toyota and self model will be Camry. Then I create the method display info. It's a public method, meaning it can be called from outside the class. This method prints the car's brand and model using the public attributes self brand and self model. Then I create instance of the car class. I pass Toyota at the brand and Camry at the model when creating this object. As we know, the it method is automatically called initializing car one brand to Toyota and car one model to Camry. And then I call the public method display inflow on the car one object. This method uses the value stored in the car one brand and car one model to display the information. And the main thing we should remember right now, since brand and model are public attributes, we can access them directly using the dot notation. So here, car one dot brand and car one dot model, print the result. So here we have the example where brand and model are public attributes, and I can access and modify them directly from outside the class using the instance of the class. And here's how I can do this. Of course, here I can replace model. The method display in for is public, meaning it can be called from outside the class of any instance of the class. Now we directly modify the public attributes, brand and model of the car one object because attributes are public, they can be modified freely from outside the class without any restriction. So after this, we have brand Honda and model Accord. Now, if we call display in for method again on car one, the method will print totally different message. I mean, we will get new brand and new model. So quick recap, public members, attributes and methods are fully accessible from outside the class. This is useful for attributes and methods you want to expose for general use. However, if you need to protect certain data from being accessed or modified directly, you would use protected or private members. I will comment out the previous code, and let's continue with protected members. So here we have totally different logic. I create class person the person class represents a person with two protected attributes name and age. The living underscore in the attribute names, name and age is a convention to indicate that these are protected members. This convention tells other developers that these attributes are meant for internal use and shouldn't be directly accessed from outside the class or subclasses. Here we have method display info and it's also a protected method. It follows the same naming convention with a leading underscore. It prints out the name and age of the person by adding the protected attributes name and age. Then I create the employee class that inherits from the person class. It means that it can use the attributes and methods of the person class. The NIT method in the employee class calls the NIT method of the person class using super function. This initializes the name and age attributes. The employee class introduces a new attribute, employee ID, which is specific to employee. The method show details in the employee class calls the protected method display info from the parent person class. This is allowed because the method is protected, which means it can be accessed from the subclass employee. So here we are calling the protected method of the parent class. Show Details prints the information about the employee's name, age from the parent class, and the employee's ID. Then we create an instance of the employee class named P and passing John at the name 30 at the H and e123 at the employee ID. The constructor of the employee class calls the person classes constructor using super setting name to John and he to 30 the employee ID is set to e13. Then I call the method show details on the MP object. Inside show details, the method display Info method from the person class is called which prints name John and H 30. After that, the employee's ID e123 is printed also. So the main point in Python protected members are attributes or members that are intended to be used only within the class and by its subclasses. They are indicating by a single leading underscore before the attribute or method name. Protected members are not strikely private. Python's naming convention with a leading underscore signals to other developers that these members are meant for internal use and should not be accessed directly from outside the class. However, this is only a convention. Python doesn't enforce access restrictions. As you can clearly see here, in the person class, we had two protected attributes name and age and a protected method, display info. These members are not meant to be accessed from outside the class directly, but they can be accessed if needed, since Python doesn't enforce strict access control. So I create an instance of the person class, person one. And I access person one name and person one, each directly outside the class. Even though it's generally considered bad practice to access protected members outside the class, but then allows it because it doesn't have strict enforcement mechanism like other languages like Java or C plus plus. I also called display info director from outside the class. Again, it's allowed, even though it's protected. So why we should follow the convention? If I follow the convention and treat protected members as internal to the class, it keeps the design of the code clean and modular. External code should not rely on the internal structure of a class because this makes future changes to the class more difficult. By following this convention, you prevent the internal details of the class from being accessed or changed directly, which helps avoid bugs. So quick recap. In Python protected members are a convention, not an enforced rule. You can access them outside the class or subclass, but doing so breaks the intended structure and can lead to bad software design. Let's proceed with private members. I comment out previous code. I create the bank account class that has two private attributes account number and balance. Private members in Python are prefixed with double underscores. I also create the method display balance and it's a private method too. It's only intended to be used within the class. The double underscore prefixes also applies to methods, which means this method is not accessible from outside the class. Then I create the withdrawal method. It's a public method. It can be accessed from outside the class. This method interacts with the private attributes balance and calls the private method display balance. It checks if there are sufficient funds for the withdrawal. If, yes, it deducts the amount from the balance. Otherwise, it prints insufficient funds. After processing the withdrawal, it calls the private method display balance to show the updated balance. I create the instance of the bank account with account number and initial balance of 1,000. When I call account withdraw 200, it will deduct 200 from the initial balance 1,000, resulting in a new balance of 800. If I run this code, we'll see a message showing that 200 was withdrawn, leaving a balance of 800. If I try to access account balance or account display balance from outside the class, it will raise an attribute error because these members are private and private members are attributes or methods that are intended to be hidden from outside access. The same error I will get if I try to access the private method display balance. By keeping attributes private, you can provide controlled access through public methods like withdraw, ensuring that the data remains in a valid state. Despite the fact that Python uses private attributes to enforce the principle of encapsulation, where the internal state of an object is protected from direct modification. PyTN also has a mechanism called name mangling that allows you to access these private attributes in a special way. This means you can still access the private attributes if you know the mangled name, but it's considered bad practice because it breaks the encapsulation principle. This line of code allows to access the private attribute using its mangled name, and it's one underscore, class name, to underscore attribute name. Yes, it's harder, but it's possible to access outside the class. And this mechanism is called name mangling. So quick recap about private attributes. In Python, they are created using double underscore and they are meant to be hidden from direct access. But Python uses name mangling to change the name of private attributes, making them harder, but not impossible to access from outside the class. You can still access private attributes using the mangled name, but it's bad practice. The preferred way to interact with private data is through public methods, which ensure data integrity and maintain encapsulation. For now, that's all. In the next lesson, we will cover getters and setters, see you in the next lesson. 37. OOP in Python: Getters, Setters, and Properties: Welcome back, guys. Let's dive into detailed explanation of encapsulation using Getters and errors in Python. We already know what encapsulation is, Encapsulation also provides a way to control access to the data by making certain attributes private and exposing them only through methods. Getters and setters are methods used to access and modify private attributes of a class. They provide a controlled way to interact with private data, ensuring that the data is handled properly and securely. The purpose of using Getters and setters is to protect data from direct external access and modifications. Let's see how this works in practice. I start from initialization class employee, and this class has two private attributes, name and salary. These attributes are prefixed with double underscores, making them private and not directly accessible from outside the class. It's time to use Getter for name. This method returns the value of the private attribute name. By using a Getter, you can control how the attribute is accessed. For example, you could add logging or additional checks later without changing the rest of your code. And after Getter, we will set setter. This method updates the value of the private attribute name. It directly assigns the new value without validation, but you could add checks if it needed. I also create Getter for salary. Similarly, this method returns the value of the private attribute salary. And setter for salary. This method includes validation to ensure the salary is positive before updating the private attribute salary. If an invalid value like negative salary is passed it prints an error message and doesn't update the salary. So then I create an instance of employee class, set the name Alice and the salary 4,000. And now I can use Getter. I will use Get name. Method retrieves the name of the employee. Let's test setter for salary. This method updates salary to 6,000, and also I will use Gary Getter. This method retrieves the updated salary. So when I run this code, the get name method is called, and it returns the private attribute name. Since name was said to Ellis, the output is Allie. Then the set salary method is called with 6,000 at the argument. Since 6,000 is greater than zero, the setter updates the private attribute salary to 6,000 and then get salary method is called, and it returns the updated value of the private attribute salary. Since the setter updated salary to 6,000, the output is 6,000. So quick recap, Getters and setters are methods that provide controlled access to private attributes. They help in achieving encapsulation, data validation and flexibility. Use Getters to retrieve private data and setters to modify it with optional validation. Removing setters can make an attribute read only while removing getters can make it we only. This approach ensures that your code is cleaner, more secure and easier to maintain. By using Getters and setters, you can protect the internal state of your object and ensure that data integrity is maintained throughout your application. But that's not all. In Python, you can use the property decorator to simplify the creation of getter and setter methods for private attributes. This allows you to access and modify private attributes in more Pythonic way without explicitly calling Getter and Setter methods. The property decorator allows you to define methods that act like attributes, giving the illusion of direct attribute access while still allowing you to define custom behavior for getting and setting the values. This approach is more concise and clean compared to the traditional Getter setter method. Simple property decorator is used to create a getter method. It allows you to access a private attribute in a controlled way without calling a method explicitly. Setter decorator uses slightly different signature. It's used to define a setter method. This allows you to modify a private attribute while also adding logic such as validation. So a slightly modified employee class example to use property decorators for getters and setters. Here after creating an instance of employee, I can access private attribute via Getter. Name. Then I can modify private attribute via setter and I set 6,000 valid salary. I also can access the updated value. If I attempt to set an invalid salary, I will have the message invalid salary. What are the advantages of using property decorators? Cleaner syntax. You can access and modify attributes using the same syntax as public attributes without explicitly calling Getter and Setter methods. The internal state of the object remains protected and you can control access to it through Getters and setters, adding validation logic if necessary. You can easily add or modify logic in the Getter setter methods without changing the external interface of the class. Using property decretors in Python allows you to achieve Getter and Setter behavior in more concise and elegant way. It simplifies the code and this approach is cleaner than manually defining getter and setter methods and helps maintain good encapsulation practices. For now, that's all. See you in the next lesson. 38. OOP in Python: Aggregation: Welcome back, guys. Today we will look at aggregation. Both aggregation and composition are types of associations that define relationships between objects in object oriented programming. Composition we discovered in the previous lesson. Now let's continue with aggregation. Aggregation has a relationship where one object can contain other objects, but those objects can still exist on their own separate from the container. The contained objects can exist even if the container object is destroyed. For example, consider a university and its students. If the university object is deleted, the student object will still exist independently. Let's continue with example. I'm going to create class book. It has the method in it is a special method in Python, constructor. It initializes a new book object. Each book object will have a title attribute. Then I create class library. It also has constructor. It creates an empty list named books. This list will be used to store book objects. The library class has a list of book objects, but it doesn't own them. The books can exist independently of the library. Then I define a method named at book in the library class. The method takes two arguments, self, the instance of library, and book, instance of book. This method adds the book object to the book list within the library instance. So then I created two book objects with the different titles. Then I create library object named Library. The method addBook is called twice to add Book one and Book two to the library's books list. What we have now, we have library with several books. Let's add the method display books. This method allows you to see all the books that have been added to the library. This method simply checks if there are any books in the library. If books are present, it displays their titles using a four loop. Self books is a list that belongs to the library object. It was empty before we add two books. Each item in this list is a book object. The line for book in self books means take each item from the self books list one by one and assign it to the variable book. The loop will continue until there are no more items left in the list. In our example, this happens twice, and then we print the book title. Here we have the title attribute of the current book object. We already know that we can access the attributes of an object using dot notation. This means you can refer to an object properties and methods by writing the object's name followed by dot and then attribute name. In our case, we have book object, and also we have title like it's attribute. Let's test it. If I now run this code, we will get the list of books. Let's delete the library. Method deletes the library object from memory. This means that the library object and its attributes, like the list of books is contained are no longer accessible. Despite deleting the library, the Book one and Book two objects still exist in memory because they were created independently of the library, and we can access it. For example, here I print book one title, and here we have the result. I'll comment out the previous unnecessary code because we don't have the library object anymore, and here we clearly see the result. And this demonstrates aggregation. The library has a collection of books, but it doesn't own them. The book objects are not destroyed when the library is destroyed. I still can work because book one exists independently of the library. Book two exists and we can use it. The library class contains a list of book objects, self books. However, these book objects are not tightly bound to the library. They can exist on their own independent of the library. And on the other hand, the book objects, Book one and Book two are independent of the library object. Deleting the library object doesn't affect the existence of book objects. This code structure helps in designing modular and flexible programs where components like books can be reused independently of the main container like a library. Quick recap, aggregation used when the one object uses another object temporarily and doesn't own it. Composition used when one object is completely dependent on another for its existence. The contained object cannot exist without the container. For example, human, heart or car and engine, but the overall approach in code for both composition and aggregation is similar. Both use classes containing references to other objects. The key difference is that in composition, the contained object depend entirely on the parent and are destroyed with it. While in aggregation, the contained objects can exist independently of the parent. I hope I was able to clearly explain the difference. For now, that's all. See you in the next lesson. 39. OOP in Python: Abstraction: Welcome back, guys. Now, let's dive into the concept of abstraction in object oriented programming. Abstraction is one of the fundamental concepts that helps in maintaining the complexity of code by hiding unnecessary details and exposing only the essential features. Input and abstraction allows you to focus on what an object does rather than how it does it. It's the process of hiding the implementation details of an object or function, and showing only the necessary and relevant features to the user. This is the concept of modeling real world objects in a way that focuses on their essential attributes and behaviors, ignoring any relevant details. For example, consider a car object. You only need to know how to start the car. You don't need to understand the internal mechanism. In programming, abstraction helps us to represent these objects and their interactions without going into the complexity of their underlying implementation. In Python, abstraction is achieved by using abstract classes and abstract methods. Abstract classes, these are classes that cannot be instantiated directly. They can only be used as a blueprint for other classes. Abstract methods, methods that are declared, but contain no implementation. Any subclass inheriting from an abstract class must provide an implementation for these methods. To implement abstraction and Python, we use the ABC abstract base class module, which provides the ABC class and the abstract method decorator. Let's see how this works in practice. So the first step, I import the abstract base class from ABC module, and also I import abstract method. Then I define an abstract class by inheriting from ABC class. Here's I define abstract method. It will be make sound. Right now we have animal like abstract class and make sound like abstract method with no implementation. Now I'm going to create subclasses that inherit from the abstract class and provide implementations for the abstract methods. I create the dog class. Here I implement Mesund then I create cat. Cat inherits also from the abstract class animal. These two classes provide implementation of the make sound method. Then I create instances of the subclasses, dog and cat, and I print make sound for dog instance and print make sound for cat instance. If I run this code, we see that the output is different. Trying to instantiate the abstract class directly will raise an error. I cannot do like this. I cannot create an instance of an abstract class directly because it contains one abstract method that has no implementation. Trying to do so will result in a type error as Python doesn't know how to execute this implemented method. By inheriting from an abstract class, subclass is saying I will provide the specific implementation for all abstract methods of the parent class. Once the subclass implements the abstract methods, you can create an instance of the subclass and use the complete behavior defined by the abstract class and its own additional logic. Let's consider this with another example. I commented out previous code so I create account. It's an abstract class that inherits from ABC. This makes account an abstract base class. The need method initializes an instance with an initial balance attribute, which represents the money in the account. This balance is stored as an instance variable self balance. And then I create two abstract methods deposit and withdraw. These methods are defined but not implemented in the abstract class account. The abstract method decorator ensures that these methods must be implemented by any non abstract subclass of account. Deposit a method that will increase the account balance by the given amount. Withdraw a method that will decrease the account balance by the given amount. Even our funds are available. Since account is an abstract class, these methods are only decorations and no functionality is provided. Any class inheriting from account must implement these methods to define the actual deposit and withdraw behavior. Then I create saving account is a concrete class that inherits from account. It provides specific implementation for the deposit and withdraw methods. The logic here is when deposit is called with an amount, the balance of the account is increased by that amount, and then we print the updated balance. The withdrawal method checks if the amount to be withdrawn is less than or equal to the current balance. If there are sufficient funds, the amount is subtracted from the balance and the updated balance is printed. If there are insufficient funds, the message is printed to indicate this. This implementation of saving account ensures that users cannot withdraw more than balance available, enforcing the rule for saving accounts. Checking Account is another concrete subclass of account, but it implements the deposit and withdraw methods differently than saving account. The deposit method works the same as in saving account, adding amount to the balance and printing the new balance. In this case, the withdraw method doesn't check for sufficient funds. Instead, it allows overdraft, meaning the balance can do negative. When amount is withdrawn, it's simply subtracted from the balance, even if the balance becomes negative. This behavior represents a typical checking account where overdraft are allowed, and the bank might provide a credit limit or charge fees for such transactions. The abstract class account enforces that any subclasses, like saving account or checking account, must implement the deposit and withdraw methods. This way, all account types have a consistent interface, allowing you to handle them the same way in your code. So you can deposit or withdraw from any type of account. The abstract class provides a common interface, while the subclasses implement the specific logic. The structure makes the code easier to maintain and extend. For example, if you wanted to add another type of account like business account, you could easily do so by creating a new subclass that implements the required methods. A saving account object starts with the balance of 1,000. You can deposit and withdraw, but withdrawals can exceed the balance. Here we had 1,000, then deposit 500. And then I try to withdraw 2000. And if I run this code, we will see the message sufficient funds. While a checking account object starts with also 1,000 as well. But it allows overdrafts. So you can withdraw more than balance, resulting in a negative balance. And if I run this code, I will get an error because sorry misspelling, that's much better. Now it works. This code shows how abstract classes can be used to create a general template for different types of accounts. It allows flexibility by letting subclasses customize specific methods like deposit and withdraw. This approach uses polymorphism and ensures that all subclasses implement necessary methods, making this code more reliable and easier to maintain. So quick recap. Abstraction is a core concept in object oriented programming that focuses on hitting complex implementation details and showing only the essential features to the user. It allows you to define a blueprint for a class, specifying what methods should do, but not how they should do this. In Python abstraction is achieved using abstract classes and abstract methods. An abstract class is a class that cannot be instantiated on its own. And is used only as base for other classes. An abstract method is a method then declared abstract class without any implementation. Subclasses must provide their own implementation of these methods. Abstraction helps to reduce complexity by simplifying the instruction with objects and force a consistent interface for subclasses and encourage code reuse and modularity, making the code easier to maintain and extend. Congratulations on completing this section on object oriented programming. Let's move on to other topics.