【模組三】Python高階

七星海棠^_~發表於2024-07-06

物件導向基礎

類和物件
概念

物件導向程式設計(Object-Oriented Programming,簡稱OOP)是一種程式設計正規化。

類是人們抽象出來的一個概念,所有擁有相同屬性功能的事物稱為一個類;而擁有相同屬性和功能的具體事物則成為這個類的例項物件。

物件導向程式設計提供了一種從現實世界中抽象出概念和實體的方法。透過類和物件的概念,可以將現實世界中的問題和關係轉化為程式碼結構,使得程式更加符合問題域的模型化。

物件導向程式設計(Object-Oriented Programming,OOP)相較於程序導向程式設計(Procedural Programming)有以下優點:

  1. 封裝性(Encapsulation):物件導向程式設計透過將資料和操作封裝在一個物件中,使得物件成為一個獨立的實體。物件對外部隱藏了內部的實現細節,只暴露出必要的介面,從而提高了程式碼的可維護性和模組化程度。
  2. 繼承性(Inheritance):繼承是物件導向程式設計的重要特徵之一。它允許建立一個新的類(子類),從一個現有的類(父類或基類)繼承屬性和方法。子類可以透過繼承獲得父類的特性,並可以在此基礎上進行擴充套件或修改。繼承提供了程式碼重用的機制,減少重複編寫程式碼的工作量。
  3. 多型性(Polymorphism):多型性使得物件可以根據上下文表現出不同的行為。透過多型機制,可以使用統一的介面來處理不同型別的物件,而不需要針對每種型別編寫特定的程式碼。這提高了程式碼的靈活性和可擴充套件性。
  4. 程式碼的可維護性和可擴充套件性:物件導向程式設計強調模組化和程式碼複用,透過將功能劃分為獨立的物件和類,使得程式碼更易於理解、測試和維護。當需求變化時,物件導向程式設計的結構和機制使得程式碼的修改和擴充套件更加簡潔和可靠。

總的來說,物件導向程式設計提供了一種更加結構化、可擴充套件和可維護的程式設計正規化。它透過封裝、繼承和多型等特性,使得程式碼更加模組化、靈活和易於理解。這些優點使得物件導向程式設計成為當今廣泛採用的程式設計正規化之一,被廣泛應用於軟體開發中。

語法

物件導向最重要的概念就是類(Class)和例項(Instance),必須牢記類是抽象的模板,例項是根據類建立出來的一個個具體的”物件“。

# 宣告類
class 類名:
    類屬性。。。
    方法。。。
    
# 類的例項化
例項物件 = 類名() # 開闢一塊獨立的屬於例項空間,將空間地址作為返回值

# 例項物件可以透過句點符號呼叫類屬性和方法
例項物件.類屬性
例項物件.方法(實參)
  1. 和變數名一樣,類名本質上就是一個識別符號,命名遵循變數規範。如果由單詞構成類名,建議每個單詞的首字母大寫,其他字母小寫。
  2. 冒號 + 縮排表示類的範圍
  3. 無論是類屬性還是類方法,對於類來說,他們都不是必需的,可以有也可以沒有。另外,Python類中的屬性和方所在的位置是任意的,即他們之間沒有固定的前後次序。
例項屬性和例項方法
例項屬性

類變數(類屬性)的特點是,所有類的例項化物件都同時共享類變數,也就是說,類變數在所有例項化物件中是作為共用資源存在的。例項屬性是屬於類的每個例項物件的特定屬性。例項屬性是在建立物件時賦予的,每個物件可以具有不同的例項屬性值。

例項方法和self

在Python的類定義中,self是一個特殊的引數,用於表示類的例項物件本身。self引數必須作為第一個引數出現在類的方法定義中,通常被約定為self,但實際上可以使用其他名稱。

當呼叫類的方法時,Python會自動將呼叫該方法的例項物件傳遞給self引數。這樣就可以透過self引數來引用和操作例項物件的屬性和方法。

構造方法__init__

構造方法在建立物件時自動呼叫,並可以接受引數來初始化物件的屬性。

例項化一個類的過程分以下幾個步驟:

  1. 建立一個新的物件(即開闢一塊獨立的空間),他是類的例項化結果。
  2. 呼叫類的__init__方法,將建立的物件作為第一個引數(通常命名為self),並傳遞其他引數(如果有的話)。
  3. __init__方法中,對物件進行初始化,可以設定物件的屬性和執行其他必要的操作。
  4. 返回新建立的物件,使其成為類的例項。
  1. 注意到__init__方法的第一個引數永遠是self,表示建立的例項本身,因此,在__init__方法內部,就可以把各種屬性繫結到self,因為self是指向建立的例項本身。
  2. 例項屬性,例項變數,例項成員變數都是指的存在例項空間的屬性。
一切皆物件

在Python語言中,一切皆物件!

字串、列表、字典等都是一個個類,我們用的所有的資料都是一個個具體的例項物件。

區別就是,那些類是在直譯器級別註冊好的,而現在我們學習的是自定義類,但語法使用都是相同的。所以,我們自定義的類例項物件也可以和其他資料物件一樣可以進行傳參、賦值等操作。

  1. 自定義類物件是可變資料型別,我們可以在建立後對其進行修改,新增或刪除屬性和方法,而不會改變類物件的身份。
  2. 例項物件也是一等公民。
類物件、類屬性以及類方法
類物件

類物件是在Python中建立類時生成的物件,它代表了該類的定義和行為,儲存著公共的類屬性和方法。

修改類屬性
類方法

定義:使用裝飾器@classmethod

第一個引數必須是當前類物件,該引數名一般約定為cls,透過它來傳遞類的屬性和方法(不能傳遞例項的屬性和方法)。

呼叫:類物件和例項物件都可以呼叫。

靜態方法

定義:使用裝飾器@staticmethod。引數隨意,沒有selfcls引數,但是方法體中不能使用類或例項的任何屬性和方法。

呼叫:類物件和例項物件都可以呼叫。

物件導向進階

繼承

物件導向的程式設計好處之一就是程式碼的重用,實現重用的方法之一就是透過繼承機制。痛毆該國繼承建立的新類稱為子類派生類,被繼承的類稱為基類父類超類

class 派生類名(基類名)
	...
繼承的基本使用

繼承是使用已存在的類定義作為基礎建立新類的技術,新類的定義可以可以增加新的資料或新的功能,也可以用父類的功能,但不能選擇性的繼承父類。透過使用繼承我們能夠非常方便的服用以前的程式碼,能夠大大的提高開發效率。

實際上繼承者是被繼承者的特殊化,它除了擁有被繼承者的特性外,還擁有自己獨有的特性。同時在繼承關係中,繼承者完全可以替換被繼承者,反之則不可以,例如我們可以說貓是動物,但不能說動物就是貓,稱之為“向上轉型”。

1、子類擁有父類非私有化的屬性和方法。

2.、子類擁有自己的屬性和方法,即子類可以對父類進行擴充套件。

3、子類可以用自己的方式實現父類的方法。

重寫父類方法和呼叫父類方法
class Person(object):

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def sleep(self):
        print(":::", self.name)
        print("基類sleep...")


class Emp(Person):

    # def __init__(self,name,age,dep):
    #      self.name = name
    #      self.age = age
    #      self.dep = dep

    def __init__(self, name, age, dep):
        # Person.__init__(self,name,age)
        super().__init__(name, age)
        self.dep = dep

    def sleep(self):
        # print("子類sleep...")
        # 呼叫父類方法
        # 方式1 :父類物件呼叫 父類物件.方法(self,其他引數)
        # Person.sleep(self)
        # 方式2: super關鍵字 super().方法(引數)
        super().sleep()


yuan = Emp("yuan", 18, "教學部")
yuan.sleep()
print(yuan.dep)
多重繼承

如果在繼承元組中列了一個以上的類,那麼它就被稱為“多重繼承”。派生類的宣告,與他們的父類類似,繼承的基類列表跟在類名之後。

class subClassname(ParentClass1[, ParentClass2,...]):
    ...
class Animal:

    def eat(self):
        print("eating...")

    def sleep(self):
        print("sleep...")

class Eagle(Animal):

    def fly(self):
        print("fly...")

class Bat(Animal):

    def fly(self):
        print("fly...")

# 多重繼承        
class Fly:
    def fly(self):
        print("fly...")
 
class Eagle(Animal,Fly):
    pass
 
class Bat(Animal,Fly):
    pass
內建函式補充
  1. typeisinstance方法
class Animal:

    def eat(self):
        print("eating...")

    def sleep(self):
        print("sleep...")


class Dog(Animal):
    def swim(self):
        print("swimming...")

alex = Dog()
mjj = Dog()

print(isinstance(alex,Dog))
print(isinstance(alex,Animal))
print(type(alex))
  1. dir()方法和__dict屬性

dir(obj)可以獲得物件的所有屬性(包含方法)列表,而obj.__dict__物件的自定義屬性字典

注意事項:

  1. dir(obj)獲取的屬性列表中,方法也認為是屬性的一種。返回的是list
  2. obj.__dict__只能獲取自定義的屬性,系統內建屬性無法獲取。。返回的是dict
class Student:

    def __init__(self, name, score):
        self.name = name
        self.score = score

    def test(self):
        pass


yuan = Student("yuan", 100)
print("獲取所有的屬性列表")
print(dir(yuan))

print("獲取自定義屬性欄位")
print(yuan.__dict__)

其中,類似__xx__的屬性和方法都是有特殊用途的。如果呼叫len()函式檢視獲取一個物件的長度,其實len()函式內部會自動去呼叫該物件的__len__()方法。

封裝

封裝是指隱藏物件的屬性和實現細節,僅對外提供公共訪問方式。

程式設計追求“高內聚,低耦合”

  • 高內聚:類的內部資料操作細節自己完成,不允許外部干涉
  • 低耦合:僅對外暴露少量的方法用於使用

隱藏物件內部的複雜性,之對外公開簡單的介面。便於外界呼叫,從而提高系統的可擴充套件性、可維護性。通俗的說,把該隱藏的隱藏起來,該暴露的暴露出來。這就是封裝性的設計思想。

私有屬性

在class內部,可以有屬性和方法,而外部程式碼可以透過直接呼叫例項變數的方法來運算元據,這樣,就隱藏了內部的複雜邏輯。但是,從前面的Student類的定義來看,外部的程式碼還是可以自由地修改一個例項的namescore屬性:

class Student(object):

    def __init__(self, name, score):
        self.name = name
        self.score = score

alvin = Student("alvin",66)
yuan = Student("yuan",88)

alvin.score=100
print(alvin.score)

如果要讓內部屬性不被外部訪問,可以把屬性的名稱前面加上兩個下劃線__,在Python中,例項的變數名如果以__開頭,就變成一個私有變數(private),只有內部可以訪問,外部不能訪問,所以,我們把Student類改一改:

class Student(object):
    
    def __init__(self, name,score):
        self.name = name
        self.__score = score
        
alvin = student('alvin', 66)
yuan = Student('yuan', 88)

print(alvin.__score)

改完之後,對於外部程式碼來說,沒什麼變動,但是已經無法從外部訪問例項變數.__name例項變數.__score。這樣就確保了外部程式碼不能隨意修改物件內部的狀態,這樣透過限制的保護,程式碼更加健壯。

可以透過定義get_scoreset_score方法來獲取和修改score。為什麼要定義一個方法呢?因為在方法中,可以做其他操作,比如記錄操作日誌,物件引數做檢查,避免輸入無效引數等。

注意

1、這種機制也沒有真正意義上限制我們從外部直接訪問屬性,知道類名和屬性名就可以拼出名字:_類名__屬性,然後就可以訪問了

2、變形的過程只在類的內部生效,在定義後賦值操作,不會變形

class Student(object):

    def __init__(self, name, score):
        self.name = name
        self.__score = score

    def get_score(self):
        return self.__score

yuan=Student("yuan",66)
print(yuan.__dict__)
yuan.__age=18    # 注意點2
print(yuan.__dict__)

子類無法直接訪問父類的私有屬性。子類只能在自己的方法中訪問和修改自己定義的私有屬性,無法直接訪問弗雷德私有屬性。

頭尾雙下劃線、雙下劃線、單下劃線說明:

  • __foo__:定義的是特殊方法,一般是系統定義名字,類似__init__()之類的。
  • __foo:雙下劃線的表示私有型別(private)的變數,只能是允許這個類本身進行訪問。
  • _foo:單下劃線表示protected型別的變數,即保護型別只能允許其本身與子類進行訪問。(約定俗成,語法不限制)
私有方法

私有方法只能在內部訪問和呼叫,無法在類的外部直接訪問或呼叫。

class AirConditioner:
    def __init__(self):
        # 初始化空調
        pass

    def cool(self, temperature):
        # 對外製冷功能介面方法
        self.__turn_on_compressor()
        self.__set_temperature(temperature)
        self.__blow_cold_air()
        self.__turn_off_compressor()

    def __turn_on_compressor(self):
        # 開啟壓縮機(私有方法)
        pass

    def __set_temperature(self, temperature):
        # 設定溫度(私有方法)
        pass

    def __blow_cold_air(self):
        # 吹冷氣(私有方法)
        pass

    def __turn_off_compressor(self):
        # 關閉壓縮機(私有方法)
        pass

在繼承中,父類如果不想讓子類覆蓋自己的方法,可以將方法定義為私有的。

class Base:
    def foo(self):
        print("foo from Base")

    def test(self):
        self.foo()

class Son(Base):
    def foo(self):
        print("foo from Son")

s=Son()
s.test()


class Base:
    def __foo(self):
        print("foo from Base")

    def test(self):
        self.__foo()

class Son(Base):
    def __foo(self):
        print("foo from Son")

s=Son()
s.test()
property屬性操作
  • property屬性裝飾器

使用介面函式獲取修改資料和使用點方法設定資料相比,點方法使用更方便。

property屬性裝飾器能使用點方法,同時也能讓點方法直接呼叫我們的函式。

class Student(object):

    def __init__(self,name,score,sex):
        self.__name = name
        self.__score = score
        self.__sex = sex

    @property
    def name(self):
        return self.__name

    @name.setter
    def name(self,name):
        if len(name) > 1 :
            self.__name = name
        else:
            print("name的長度必須要大於1個長度")

    @property
    def score(self):
        return self.__score

    # @score.setter
    # def score(self, score):
    #     if score > 0 and score < 100:
    #         self.__score = score
    #     else:
    #         print("輸入錯誤!")


yuan = Student('yuan',18,'male')

yuan.name = '苑昊'  #  呼叫了@name.setter

print(yuan.name)    #  呼叫了@property的name函式

yuan.score = 199    # @score.setter
print(yuan.score)   # @property的score方法 
  • property屬性函式

Python提供了更加人性化的操作,可以透過限制方式完成只讀、只寫、讀寫、刪除等各種操作。

class Person:
    def __init__(self, name):
        self.__name = name

    def __get_name(self):
        return self.__name

    def __set_name(self, name):
        self.__name = name

    def __del_name(self):
        del self.__name
    # property()中定義了讀取、賦值、刪除的操作
    # name = property(__get_name, __set_name, __del_name)
    name = property(__get_name, __set_name)

yuan = Person("yuan")

print(yuan.name)   # 合法:呼叫__get_name
yuan.name = "苑昊"  # 合法:呼叫__set_name
print(yuan.name)

# property中沒有新增__del_name函式,所以不能刪除指定的屬性
del yuan.name  # 錯誤:AttributeError: can't delete Attribute

@property廣泛應用在類的定義中,可以讓呼叫者寫出簡短的程式碼,同時保證對引數進行必要的檢查,減少了程式執行時出錯的可能性。

多型
鴨子模型

鴨子模型(Duck typing)是一種動態型別系統中的程式設計風格或理念,它強調物件的行為比其具體型別更重要。根據鴨子模型的說法,如果一個物件具有與鴨子相似的行為,那麼他就可以被視為鴨子。

鴨子模型源自於一個簡單的說法:“如果它看起來像鴨子,叫起來像鴨子,那麼他就是鴨子。”在程式設計中,這意味著我們更關注物件是否具有特定的方法或屬性,而不是關注物件的具體型別。

透過鴨子模型,我們可以編寫更靈活、通用的程式碼,而不需要顯式地指定特定的型別或繼承特定的介面。只要物件具有所需的方法和屬性,就可以在程式碼中使用它們,無論物件的具體型別是什麼。

反射

反射主要是指程式可以訪問、減冊和修改它本身狀態或行為的一種能力。

在Python中,反射是指執行時透過名稱字串來訪問、檢查和操作物件的屬性和方法的能力。Python提供了一些內建函式和特殊方法,使得可以動態的獲取物件的資訊並執行相關操作。

# 反射主要方法:
# 1. 判斷物件中有沒有一個name字串對應的方法或屬性
hasattr(object, name)
# 2. 獲取物件name字串屬性的值,如果不存在返回default的值
getattr(object, name, default=None)
# 3. 設定物件的key屬性為value值,等同於object.key = value
setattr(object, key, value)
# 4. 刪除物件的name字串屬性
delattr(object, name)
class Person:
    def __init__(self,name,age,gender):
        self.name = name
        self.age = age
        self.gender = gender

yuan=Person("yuan",22,"male")
print(yuan.name)
print(yuan.age)
print(yuan.gender)
while 1:
    # 由使用者選擇檢視yuan的哪一個資訊
    attr = input(">>>")
    if hasattr(yuan, attr):
        val = getattr(yuan, attr)
        print(val)
    else:
        val=input("yuan 沒有你該屬性資訊!,請設定該屬性值>>>")
        setattr(yuan,attr,val)
class CustomerManager:
    def __init__(self):
        self.customers = []

    def add_customer(self):
        print("新增客戶")

    def del_customer(self):
        print("刪除客戶")

    def update_customer(self):
        print("修改客戶")

    def query_one_customer(self):
        print("查詢一個客戶")

    def show_all_customers(self):
        print("查詢所有客戶")


class CustomerSystem:
    def __init__(self):
        self.cm = CustomerManager()

    def run(self):
        print("""
           1. 新增客戶
           2. 刪除客戶
           3. 修改客戶
           4. 查詢一個客戶
           5. 查詢所有客戶
           6. 儲存
           7. 退出
        """)

        while True:
            choice = input("請輸入您的選擇:")

            if choice == "6":
                self.save()
                continue
            elif choice == "7":
                print("程式退出!")
                break

            try:
                method_name = "action_" + choice
                method = getattr(self, method_name)
                method()
            except AttributeError:
                print("無效的選擇")

    def save(self):
        print("儲存資料")

    def action_1(self):
        self.cm.add_customer()

    def action_2(self):
        self.cm.del_customer()

    def action_3(self):
        self.cm.update_customer()

    def action_4(self):
        self.cm.query_one_customer()

    def action_5(self):
        self.cm.show_all_customers()


cs = CustomerSystem()
cs.run()
魔法方法

Python的類裡提供,兩個下劃線開始,兩個下劃線結束的方法就是魔法方法。魔法方法在特定的行為下會被啟用,自動執行。

【1】__new__()方法

在Python中定義一個類的時候可以定義的一個特殊方法。它被用來建立一個類的新例項(物件)。

建立一個新的例項一般是透過呼叫類的建構函式__init__()來完成的。然而類名()建立物件時,在自動執行__init__()方法前,會先執行object.__new__方法,在記憶體中開闢物件空間並返回該物件。然後,Python才會呼叫__init__()方法來對這個新例項進行初始化。

class Person(object):
    # 其中,cls參數列示類本身,*args 和 **kwargs引數用於接收傳遞給建構函式的引數。
    def __new__(cls, *args, **kwargs):
        print("__new__方法執行")
        return object.__new__(cls)

    def __init__(self, name, age):
        print("__init__方法執行")
        self.name = name
        self.age = age

yuan = Person("yuan", 23)

__new__()方法的主要作用就是建立例項物件,它可以被用來空值例項的建立過程。相比之下,__init__()方法主要用於初始化例項物件。

__new__()方法在設計模式中常與單例模式結合使用,用於建立一個類的唯一實現。單例模式是一種建立型設計模式,他確保一個類只有一個例項,並提供一個全域性訪問點來獲取該例項。

class Singleton:
    instance = None

    def __new__(cls, *args, **kwargs):
        if not cls.instance:
            cls.instance = object.__new__(cls)
        return cls.instance

S1 = Singleton()
S2 = Singleton()
print(id(S2))
print(id(S1))
print(S1 is S2)
【2】__str__方法

改變物件的字串顯示。可以理解為使用print函式列印一個物件時,會自動呼叫物件的__str__方法。

class Person(object):

    def __init__(self, name, age):
        print("__init__方法執行")
        self.name = name
        self.age = age

    def __str__(self):
        return self.name

yuan = Person("yuan", 23)
print(yuan)

# 案例2
class Book(object):

    def __init__(self, title, publisher, price):
        self.title = title
        self.publisher = publisher
        self.price = price


book01 = Book("金蘋果", "蘋果出版社", 699)
print(book01)
【3】__eq__方法
class Person(object):

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __eq__(self, obj):
        return self.age == obj.age


yuan = Person("yuan", 23)
alvin = Person("alvin", 23)
print(yuan == alvin)
  1. __eq__(self, other):判斷物件是否相等,透過==運算子呼叫。
  2. __lt__(self, other):判斷物件是否小於另一個物件,透過<運算子呼叫。
  3. __gt__(self, other):判斷物件是否大於另一個物件,透過>運算子呼叫。
  4. __add__(self, other):物件的加法操作,透過+運算子呼叫。
【4】__len__方法

當定義一個自定義的容器類時,可以使用__len__()方法來返回容器物件中元素的數量。

class Cache01:
    def __init__(self):
        self.data = []

    def __len__(self):
        return len(self.data)

    def add(self, item):
        self.data.append(item)

    def remove(self, item):
        self.data.remove(item)


# 建立自定義列表物件
cache = Cache01()

# 獲取列表的長度
print(len(cache))


class Cache02:
    def __init__(self):
        self.data = {}

    def __len__(self):
        return len(self.data)

    def __str__(self):
        return str(self.data)

    def add(self, key, value):
        self.data[key] = value

    def remove(self, key):
        del self.data[key]

c2 = Cache02()
c2.add(10, "100")
print(c2)

為什麼要封裝這個類,直接使用列表或者字典不行嗎?

當我們封裝一個類時,我們將相關的資料和操作放在一個包裹中(類),就像把一些東西放進一個盒子裡一樣。這個盒子提供了一種保護和管理資料的方式,同時也定義了外部與內部之間的互動方式。

為什麼要這樣做?想象一下,如果我們直接將資料儲存在類之外的變數中,其他程式碼可以直接訪問和修改它。這可能導致資料被誤用或篡改,造成不可預測的結果。而透過封裝,我們可以將資料放在類的內部,並提供一些方法或介面來訪問和修改資料。就像將資料放進盒子裡,並用盒子上的們來控制對資料的訪問。

這種封裝的好處是:首先,它提供了一種資訊隱藏機制。外部程式碼只能透過類提供的方法來訪問資料,無法直接觸及資料本身。遮掩給可以保護資料的完整性和一致性,防止不恰當的訪問和修改。其次,封裝使得程式碼更加模組化和可重用。我們可以已將相關的資料和操作組織在一個類中,稱為一個功能完整的單元,方便呼叫和擴充套件。

總而言之,封裝就像把資料放進一個盒子裡,透過提供方法來控制對資料的訪問。這樣做可以保護資料,提高程式碼的可讀性和可維護性,並促進程式碼的模組化和重用。

【5】__item__系列
class Cache:
    def __init__(self):
        self.data = {}

    def __getitem__(self, key):
        return self.data[key]

    def __setitem__(self, key, value):
        self.data[key] = value

    def __delitem__(self, key):
        del self.data[key]

    def __contains__(self, key):
        return key in self.data
    
cache = Cache()

# 儲存資料
cache['key1'] = 'value1'
cache['key2'] = 'value2'

# 獲取資料
print(cache['key1'])  # 輸出: 'value1'
print(cache['key2'])  # 輸出: 'value2'

# 檢查鍵是否存在
print('key1' in cache)  # 輸出: True

# 刪除資料
del cache['key1']

# 檢查鍵是否存在
print('key1' in cache)  # 輸出: False

建立了一個名為Cache的自定義類,並實現了__getitem____setitem____delitem____contains__這些特殊方法。

【6】__attr__系列
class Cache(object):

    def __init__(self):
        self.__dict__["data"] = {}

    def __setattr__(self, key, value):
        # 有效控制,判斷,監控,日誌
        self.__dict__["data"][key] = value

    def __getattr__(self, key):
        if key in self.__dict__["data"]:
            return self.__dict__["data"][key]
        else:
            raise AttributeError(f"'Cache' object has no attribute '{key}'")

    def __delattr__(self, key):
        if key in self.__dict__["data"]:
            del self.__dict__["data"][key]
        else:
            raise AttributeError(f"'Cache' object has no attribute '{key}'")

    def __contains__(self, name):
        return name in self.__dict__["data"]


cache = Cache()
cache.name = "yuan"
cache.age = 19
print(cache.name)
del cache.age
print("age" in cache)
del cache.age
print(cache.age)

使用這個經過修改的快取類,我們可以使用類似於屬性操作的語法:物件.屬性來訪問和操作快取物件。

異常機制

異常機制是一種在程式執行過程中處理錯誤和異常情況的機制。當程式執行過程中發生異常時,會中斷正常的執行流程,並轉而執行異常處理的程式碼。這可以幫助我們優雅地處理錯誤,保證程式的穩定性和可靠性。

在Python中,異常以不同的型別表示,每個異常型別對應不同的錯誤或異常情況。當發生異常時可以使用try-except語句來捕獲並處理異常。try塊中的程式碼被監視,如果發生異常,則會跳轉到except塊中,執行異常處理的程式碼。

【1】Error型別

常見的錯誤關鍵字(Exception Keywords):

  1. SyntaxError:語法錯誤,通常是由於程式碼書寫不正確而引發的異常。
  2. NameError:名稱錯誤,當嘗試訪問一個未定義的變數或名稱時引發的異常。
  3. IndexError:索引錯誤,當訪問列表、元組或字串等序列型別時使用了無效的索引引發的異常。
  4. KeyError:鍵錯誤,當嘗試使用字典中不存在的鍵引發的異常。
  5. ValueError:值錯誤,當函式接收到一個正確型別但是不合法的值時引發的異常。
  6. FileNotFoundError:檔案未找到錯誤,當嘗試開啟或操作不存在的檔案時引發的異常。
  7. ImportError:匯入錯誤,當匯入模組失敗時引發的異常,可能是因為找不到模組或模組中缺少所需的內容。
  8. ZeroDivisionError:零除錯誤,當除法或取模運算的除數為零時引發的異常。
  9. AttributeError:屬性錯誤,當場是訪問物件不存在的屬性或方法時引發的異常。
  10. IOError:輸入輸出錯誤,當發生與輸入和輸出操作相關的錯誤時引發的異常。例如,嘗試讀取不存在的檔案或寫入檔案時磁碟已滿。
【2】基本語法

基本結構:try except

# (1) 通用異常
try:
    pass  # 正常執行語句
except Exception as ex:
    pass  # 異常處理語句

# (2) 指定異常
try:
     pass  # 正常執行語句
except <異常名>:
     pass  # 異常處理語句
        
# (3) 統一處理多個異常
try:
     pass  # 正常執行語句
except (<異常名1>, <異常名2>, ...):
      pass  # 異常處理語句
    
# (4) 分別處理不同的異常 
try:
     pass  # 正常執行語句
except <異常名1>:
      pass  # 異常處理語句1
except <異常名2>:
      pass  # 異常處理語句2
except <異常名3>:
      pass  # 異常處理語句3
  
# (5) 完整語法   
try:
    pass  # 正常執行語句
except Exception as e:
    pass  # 異常處理語句
else:
    pass # 測試程式碼沒有發生異常 
finally:
    pass  # 無論是否發生異常一定要執行的語句,比如關閉檔案,資料庫或者socket
    

機制說明:

  • 首先,執行try子句(在關鍵字try和except之間的語句)
  • 如果沒有異常發生,忽略except子句,try子句執行後結束。
  • 如果在執行try子句的過程中發生了異常,那麼try子句餘下部分將被忽略。如果異常那麼對應的except子句將被執行。
  • 通用異常:Exception可以捕獲任意異常。
【3】rasie

關鍵字rasie可以主動觸發異常。

rasie可以拋自定義異常,自定義異常應該繼承Exception類,直接繼承或者間接繼承都可以。

class CouponError01(Exception):
    def __init__(self):
        print("優惠券錯誤型別1")


class CouponError02(Exception):
    def __init__(self):
        print("優惠券錯誤型別2")


class CouponError03(Exception):
    def __init__(self):
        print("優惠券錯誤型別3")


try:
    print("start")
    print("...")
    x = input(">>>")
    if x == "1":
        raise CouponError01
    elif x == "2":
        raise CouponError02
    elif x == "3":
        raise CouponError03

except CouponError01:
    print("優惠券錯誤型別1")
except CouponError02:
    print("優惠券錯誤型別2")
except CouponError03:
    print("優惠券錯誤型別3")

網路程式設計

軟體架構設計

根據應用場景分為客戶端/伺服器(Client/Server,CS)架構和瀏覽器/伺服器(Browser/Server,BS)架構。

CS架構主要分為客戶端和伺服器。客戶端負責使用者介面和處理使用者輸入,而伺服器負責業務邏輯和儲存資料。客戶端和伺服器之間透過網路進行通訊,客戶端傳送請求給伺服器,伺服器進行處理並返回結果給客戶端。

BS架構中,伺服器重要負責業務邏輯和資料處理,而客戶端主要負責展示和使用者互動。伺服器端可以使用不同的技術棧。

網路三要素
  1. 地址(Address):地址用於唯一標識網路中的裝置或應用程式。在網路通訊中,每個裝置或應用程式都有一個唯一的地址,使得資料能夠準確地傳送到目標位置。在Internet中,常用的地址是IP地址(Internet Protocol Address),他是一個由數字和點分隔符組成的識別符號。IP地址可以用來表示主機或網路裝置。此外MAC地址(Media Access Control Address),用於在區域網中唯一標識網路介面。
  2. 埠(Port):埠在網路通訊中用於標識應用程式或服務的數字。每個裝置或主機的應用程式可以使用不同的埠號,以便在同一臺裝置上同時執行多個應用程式。埠號是一個16位的數字,範圍從0到65535。其中0到1023之間的埠號是一些著名的埠號,用於特定的服務或應用程式,如HTTP的埠號是80,HTTPS的埠號是443.埠號的使用確保了資料能夠正確地傳遞給目標應用程式或服務。
  3. 協議(Protocol):協議是在網路通訊中規定的一組規則和約定,用於確保資料的正確傳輸和交換。協議定義了資料的格式、傳輸方式、錯誤處理、連線建立和斷開等操作。常見的網路協議包括TCP(傳輸控制協議)、UDP(使用者資料包協議)、IP(網際網路協議)、HTTP(超文字傳輸協議)等。協議的使用確保了網路裝置中的裝置和應用程式之間可以相互通訊和理解。
TCP協議

TCP(Transmission Control Protocol,傳輸控制協議)是一種面向連線的、可靠的、基於位元組流的通訊協議,資料在傳輸前要建立連線,傳輸完畢後還要斷開連線。

客戶端在收發資料前要使用connect()函式和伺服器建立連線。建立連線的目的是保護IP地址、埠、物理鏈路等正確無誤,為資料的傳輸開闢通道。

img

  1. 序號:Seq(Sequence Number)序號佔32位,用來標識從計算機A傳送到計算機B的資料包的序列,計算機傳送資料時對此進行標記。

  2. 確認號:Ack(Number)確認號佔32位,客戶端和服務端都可以傳送,Ack = Seq + 1 。

  3. 標誌位:每個標誌位佔用1Bit,共有6個,分別為URG、ACK、PSH、RST、SYN、FIN,具體的含義如下:

    URG:緊急指標(Urgent pointer)有效

    ACK:確認序號有效

    PSH:接收方應該儘快將這個報文交給應用層

    PST:重置連線

    SYN:建立一個連線

    FIN:斷開一個連線

TCP建立連線時要傳輸三個資料包,俗稱三次握手(Three-way Handshaking)。可以形象的比喻為下面的對話:

  • [Shake 1]套接字A:“大哥,你能聽見我說話嗎”
  • [Shake 2]套接字B:“可以,小弟,你能聽見我說話嗎”
  • [Shake 3]套接字A:“我也能,OK!”

img

使用connect()建立連線時,客戶端和伺服器端會相互傳送三個資料包。

客戶端呼叫socket() 建立套接字後,因為沒有建立連線,所以套接字處於closed狀態;服務端呼叫listen() 函式後,套接字進入LISTEN狀態,開始監聽客戶端請求。這個時候,客戶端開始發起請求:

  1. 當客戶端呼叫 connect() 函式後,TCP協議會組建一個資料包,並設定SYN標誌位,標識該資料包是用來建立同步連線的。同時生成一個隨機數字1000,填充“序號(Seq)”欄位,表示該資料包的序號。完成這些工作後,開始向伺服器端傳送資料包,客戶端就進入了SYN-SEND狀態。
  2. 伺服器端收到資料包,減冊到已經設定了SYN標誌位,就知道這是客戶端發來的建立連線的“請求包”。伺服器端也會組建一個資料包,並設定SYN和ACK標誌位,SYN表示該資料包用來建立連線,ACK用來確認收到了剛才客戶端傳送來的資料包。伺服器將客戶端資料包序號(1000)加1 ,得到1001,並用這個數字填充“確認號(ACK)”欄位。伺服器將資料包發出,進入SYN-RECV狀態。
  3. 客戶端收到資料包,檢測到已經設定了SYN和ACK標誌位,就知道這是伺服器發來的“確認包”。客戶端會檢測“確認號(ACK)”欄位,看它的值是否為1000+1 ,如果時就是說明連線建立成功。接下來,客戶端會繼續組建資料包,並設定ACK標誌位,表示客戶端正確接收了伺服器發來的“確認包”。同時,將剛才的伺服器發來的資料包序號(2000)加1 ,得到2001,並用這個數字來填充“確認(ACK)”欄位。客戶端將資料包發出,進入ESTABLISED狀態,表示連線已經建立成功。
  4. 伺服器端收到資料包,檢測到已經設定了ACK標誌位,就知道這是客戶端發來的“確認包”。伺服器會檢測“確認號(ACK)”欄位,看它的值是否為 2000 + 1 ,如果是就說明連線簡歷成功,伺服器進入ESTABLISED狀態。至此,客戶端和伺服器進入了ESTABLISED狀態,連線建立成功,接下來就可以收發資料了。

注意:三次握手的關鍵是要確認對方收到了自己的資料包,這個目標就是透過“確認號(ACK)”欄位來實現的。計算機會記錄自己傳送的資料包序列Seq,待收到對方的資料包後,檢測“確認號(ACK)”欄位,看ACK = Seq + 1是否成立,如果成立說明對方正確接收了自己的資料包。

UDP協議

TCP是面向連線的傳輸協議,建立連線時要經過三次握手,斷開連線時要經過四次握手,中間傳輸資料時也要回復ACK確認包,多種機制保證了資料能夠正確到達,不會丟失或出錯。

UDP是非連線的傳輸協議,沒有建立連線和斷開連線的過程,他只是簡單地把資料丟到網路中,也不需要ACK包確認。

UDP傳輸資料就好像我們郵寄包裹,郵寄前需要人填好寄件人和收件人地址,之後送到快遞公司即可,但包裹是否正確送達、是否損壞我們無法得知,也無法保證。UDP協議也是如此,它只管吧資料包傳送到網路,然後就不管了,如果資料丟失或損壞,傳送端是無法知道的,當然也不會重發。

如果只考慮可靠性,TCP的確比UDP好。但UDP在結構上比TCP更加簡潔,不會傳送ACK的應答訊息,也不會給資料包分配Seq序號,所以UDP 的傳輸效率有時會比TCP高出很多,程式設計中實現UDP也比TCP簡單。

UDP的可靠性雖然比不上TCP,但也不會像想象中那麼頻繁地發生資料損毀,在更加重視傳播效率而非可靠性的情況下,UDP是一種很好的選擇。比如影片通訊或音訊通訊,就非常適合採用UDP協議;通訊時資料必須高效傳輸才不會產生“卡頓”現象,使用者體驗才更加流暢,如果丟失幾個資料包,影片畫面可能會出現“雪花”,音訊可能會夾帶一些雜音,這些都是無妨的。

與UDP相比,TCP的生命在於流控制,這保證了資料傳輸地正確性。

Socket(套接字)
【1】socket概念

socket原意“插座”,在計算機通訊領域,他是計算機之間進行通訊的一種約定或一種方式。透過socket這種約定,一臺計算機可以接收其他計算機的資料,也可以向其他計算機傳送資料。我們把插頭插到插座上就能從電網中獲得電力供應,同樣的,為了與遠端計算機進行資料傳輸,需要連線到因特網,而socket就是用來連線到因特網的工具。

socket是在應用層和傳輸層之間的一個抽象層,它的本質是程式設計介面,透過socket,才能實現TCP/IP協議。他就是一個底層套件,用於處理最底層訊息的接收和傳送。

【2】套接字的型別

根據資料的傳輸方式,可以將Internet套接字分成兩種型別。透過 socket() 函式建立連線時,必須告訴他使用哪種資料傳輸方式。

(1)流格式套接字(SOCK_STREAM)

也叫“面向連線的套接字”,在程式碼中使用SOCK_STREAM 表示。SOCK_STREAM 是一種可靠的、雙向的通訊資料流,資料可以準確無誤的到達另一臺計算機,如果損壞或丟失,可以重新傳送。

特徵:

  • 資料在傳輸中不會消失
  • 資料是按照順序傳輸的
  • 資料的傳送和接收不是同步的(不存在資料邊界)

可以將 SOCK_STREAM 比喻成一條傳送帶,只要傳送帶本身沒有問題(不會斷網),就能保證資料不會丟失;同時,較晚傳送的資料不會先到達,較早傳送的資料不會晚到達,這就保證了資料是按照順序傳遞的。

流格式套接字可以實現高質量的資料傳輸,使用了TCP協議,TCP協議會控制你的資料按照順序到達並且沒有錯誤。

“TCP/IP”:TCP用來確保資料的正確性,IP用來控制資料如何從源頭到達目的地,也就是常說的“路由”。

資料的傳送和接收不同步:

假設傳送帶傳送的是水果,接收者需要集齊100個後才能裝袋,但是傳送帶可能把這100個水果分批傳送,比如第一批傳送20個,第二批傳送50個,第三批傳送30個。接收者不需要和傳送帶保持一致,只需要根據自己的節奏來裝袋即可,不管傳送帶傳送了幾批,也不用,每到一批就裝袋一次,可以等到湊夠了100個水果再裝袋。

流格式套接字的內部有一個緩衝區(也就是字元陣列),透過socket傳輸的資料將儲存在這個緩衝區。接收端在收到資料後並不一定立即讀取,只要資料不超過緩衝區的容量,接收端有可能在緩衝區被填滿以後一次性讀取,也可能分成好幾次讀取。

也就是說,不管資料分幾次傳送過來,接收端只需要根據自己的要求讀取,不用非得在資料到達時立即讀取。傳送段有自己的節奏,接收端也有自己的節奏,他們是不一致的。

(2)資料包格式套接字(SOCK_DGRAM)

也叫“無連線的套接字”,在程式碼中使用SOCK_DGRAM表示。

計算機只管傳輸資料,不做資料校驗,如果資料在傳輸中損壞,或者沒有到達另一臺計算機,是沒有辦法補救的。也就是說,資料錯了就錯了,無法重新傳輸。

因為資料包套接字所做的校驗工作少,所以在傳輸效率方面比流格式套接字要高。

可以將SOCK_DGRAM 比喻成高速移動的摩托車快遞,它有以下特徵:

  • 強調快速傳輸而非傳輸順序
  • 傳輸的資料可能丟失也可能損毀
  • 限制每次傳輸的資料大小
  • 資料的傳送和接收時同步的(存在資料邊界)

用摩托車發往同一地點的兩件包裹無需保證順序,只要以最快的速度交給客戶就行了。這種凡是存在損壞或丟失的風險,而且包裹大小有一定的限制。因此,要想傳遞大量包裹,就得分配傳送。

另外,用兩輛摩托車分別傳送兩件包裹,那麼接收者也需要分兩次接收,所以資料的接收和傳送是同步的,接受次數和傳送次數相同。

總之,資料包套接字是一種不可靠的、不按順序傳遞的、以追求速度為目的的套接字。

資料包套接字也是用IP協議作路由,但是它不使用TCP協議,而是使用UDP協議(User Datagram Protocol,使用者資料包協議)。

QQ影片聊天和語音聊天就是使用SOCK_DGRAM 來傳輸資料的,因為首先要保證通訊的效率,儘量減少延遲,而資料的正確性是次要的,即使丟失很小一部分資料,影片和音訊也可以正常解析,最多出現噪點或雜音,不會對通訊質量有實質的影響。

【3】基於套接字的網路程式設計

img

粘包現象

粘包(Packet Congestion)是計算機網路中的一個常見問題,粘包問題通常出現在使用面向連線的傳輸協議(如TCP)進行資料傳輸時,這是因為TCP是基於位元組流的,他並不瞭解應用層資料包的具體邊界。當傳送端迅速傳送多個資料包時,底層的網路協議棧可能會將這些資料包合併成一個較大的資料塊進行傳送。同樣的,在接收端,網路協議棧也可能將接收的資料塊合併成一個較大的資料塊,然後交給應用層處理。

粘包問題可能導致資料處理的困難和不確定性。例如,在一個基於文字的協議中,接收方可能需要將接收到的資料進行分割,以便逐個處理每個完整的訊息。如果資料包粘連在一起,接收方就需要額外的處理來確定訊息的邊界,這就增加了複雜性。

client.py

import json
import socket
import os
import struct

def put(sock, file_name):
    # 上傳檔案到服務端
    file_path = './file/' + file_name
    file_size = os.path.getsize(file_path)
    params = {"file_name": file_name, "file_size": file_size, "cmd": "put"}
    # 1.上傳檔案資訊到服務端
    params = json.dumps(params).encode()
    sock.send(struct.pack('i', len(params)))
    sock.send(params)
    # 上傳檔案資料到服務端
    with open(file_path, 'rb') as f:
        for line in f:
            sock.send(line)

def get(sock, file_name):
    # 告訴服務端想要下載的檔案
    params = {"file_name": file_name, "cmd": "get"}
    json_params = json.dumps(params).encode()
    sock.send(struct.pack('i', len(json_params)))
    sock.send(json_params)
    # 接收服務端發回來的檔案資訊
    params_len = struct.unpack('i', sock.recv(4))[0]
    params_json = sock.recv(params_len)
    params = json.loads(params_json)
    file_size = params["file_size"]
    # 迴圈接收服務端發來的檔案資料
    write_size = 0
    with open('./download/' + file_name, 'wb') as f:
        while write_size < file_size:
            t = sock.recv(1024)
            f.write(t)
            write_size += len(t)

def main():
    # 1.構建客戶端套接字物件
    sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
    # 2.連線伺服器
    sock.connect(('127.0.0.1', 9999))

    while 1:
        cmd = input("put 1.jpg >>>")
        if cmd == 'exit':
            break
        cmd_type = cmd.split(' ')[0]
        file_name = cmd.split(' ')[1]

        if cmd_type == 'get':
            get(sock, file_name)
        elif cmd_type == 'put':
            put(sock, file_name)
        else:
            print("invalid cmd type!")

if __name__ == '__main__':
    main()

server.py

import socket
from loguru import logger
import json
import struct
import os

def put(conn, data):
    # 接收服務端上傳的檔案
    file_name = data['file_name']
    file_size = data['file_size']
    file_path = './upload/' + file_name

    # 迴圈接受客戶端檔案資料
    write_size = 0
    with open(file_path, 'wb') as f:
        while write_size < file_size:
            t = conn.recv(1024)
            f.write(t)
            write_size += len(t)
    logger.info(f"檔案{file_name}({file_size})上傳成功")

def get(conn, data):
    # 根據客戶端發來的想要的檔名,構建檔案資訊引數
    file_name = data['file_name']
    file_path = './upload/' + file_name
    file_size = os.path.getsize(file_path)
    params = {"file_name": file_name, "file_size": file_size, "cmd": "get"}
    # 傳送檔案資訊導客戶端
    params = json.dumps(params).encode()
    conn.send(struct.pack('i', len(params)))
    conn.send(params)
    # 傳送檔案資料到客戶端
    with open(file_path, 'rb') as f:
        for line in f:
            conn.send(line)
    logger.info(f"檔案{file_name}({file_size})下載成功")


def main():
    # 1.構建服務端套接字物件
    sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
    # 2.服務端三件套:bind listen accept
    sock.bind(('127.0.0.1', 9999))
    sock.listen(5)
    logger.info(f'服務端啟動')

    while 1:
        logger.info("等待連線...")
        # 阻塞函式
        conn, addr = sock.accept()
        print(f"conn::{conn},addr::{addr}")
        logger.info(f"來自客戶端{addr}的請求成功")

        while 1:
            params_length = conn.recv(4)

            param_length = struct.unpack('i', params_length)[0]
            logger.info(f"param_length:{param_length}")
            json_data = conn.recv(param_length)
            data = json.loads(json_data)
            logger.info(f"data:{data}")

            cmd_type = data['cmd']
            if cmd_type == 'put':
                put(conn, data)
            elif cmd_type == 'get':
                get(conn, data)
            else:
                print("invalid cmd!")


if __name__ == '__main__':
    main()

併發程式設計

程序、執行緒與協程
程序

計算機的核心時CPU,它承擔了所有的計算任務;而作業系統是計算機的管理者,它負責任務的排程、資源的分配和管理,統領整個計算機硬體;應用程式則是具有某種功能的程式,程式是執行於作業系統之上的。

程序是一個具有一定獨立功能的程式在一個資料集上的一次動態執行的過程,是作業系統進行資源分配和排程的一個獨立單位,是應用程式執行的載體。

多道技術:空間複用 + 時間複用,於是有了多程序!

程序是一種抽象的概念,從來沒有統一的標準定義。程序一般是由程式、資料集和程序控制塊三部分組成。

程序狀態反映程序執行過程的變化。這些狀態隨著程序的執行和外界條件的變化而轉換。在三態模型中,程序狀態分為三個基本狀態,即執行態,就緒態,阻塞態。在五態模型中,程序分為新建態、終止態、執行態、就緒態、阻塞態。

img

執行緒

在早期的作業系統中並沒有執行緒的概念,程序是能擁有資源和獨立執行的最小單位,也是程式執行的最小單位。任務排程採用的是時間片輪轉的搶佔式排程方式,而今城市任務排程的最小單位,每個程序有各自獨立執行的一塊記憶體,使得各個程序之間的記憶體地址相互隔離。後來,隨著計算機的發展,對CPU的要求越來越高,程序之間的切換開銷較大,已經無法滿足越來越複雜的程式要求了。於是就發明了執行緒。

執行緒是程式執行中一個單一的順序控制流程,是程式執行流的最小單元,是處理器排程和分派的基本單位。

一個程序可以有一個或多個執行緒,各個執行緒之間共享程式的記憶體空間(也就是所在程序的記憶體空間)。一個標準的執行緒由執行緒ID、當前指令指標PC、暫存器和堆疊組成。而程序由記憶體空間(程式碼、資料、程序空間、開啟的檔案)和一個或多個執行緒組成。

【生命週期】

在單個處理器執行多個線城市,併發是一種模擬出來的狀態。作業系統採用時間片輪轉的方式輪流執行每一個執行緒。現在,幾乎所有的現代作業系統採用的都是時間片輪轉的搶佔式排程方式,如我們熟悉的Unix、Linux、Windows及macOS等流行的作業系統。

我們知道的執行緒是程式執行的最小單位,也是任務執行的最小單位。在早期只有程序的作業系統中,程序有五種狀態,建立、就緒、執行、阻塞(等待)、退出。早期的程序相當於現在的只有單個執行緒的程序,那麼現在的多執行緒也有五種狀態,現在的多執行緒的生命週期與早期程序的生命週期類似。

建立:一個新的執行緒被建立,等待該執行緒被呼叫執行;

就緒:時間片用完,此執行緒被強制暫停,等待下一個屬於他的時間片到來;

執行:此執行緒正在執行,正在佔用時間片;

阻塞:也叫等待態,等待某一事件(如 IO 或另一個執行緒)執行完;

退出:一個執行緒完成任務或者其他終止條件發生,該執行緒終止進入退出狀態,退出狀態釋放該執行緒所分配的資源。

程序與執行緒的區別

  1. 執行緒是程式執行的最小單位,而程序是作業系統分配資源的最小單位;
  2. 一個程序由一個或多個執行緒組成,執行緒是一個程序中程式碼的不同執行路線;
  3. 程序之間相互獨立,但同一程序下的各個執行緒之間共享程式的記憶體空間(包括程式碼段、資料集、堆等)及一些程序級的資源(如開啟檔案和訊號),某程序內的執行緒在其他程序不可見;
  4. 排程和切換:執行緒上下文切換比程序上下文切換要快得多。
協程 Coroutines

協程,英文Coroutines,是一種基於執行緒之上,但又比執行緒更加輕量級的存在,這種由程式設計師自己寫程式來管理的輕量級執行緒叫做【使用者空間執行緒】,具有對核心來說不可見的特性。因為是自主開闢的非同步任務,所以很多人也更喜歡叫它纖程(Fiber),或者綠色執行緒(GreenThread)。正如一個程序可以擁有多個執行緒一樣,一個執行緒也可以擁有多個協程。

協程解決的是執行緒的切換開銷和記憶體開銷的問題。

將多個使用者級執行緒對映到一個核心級執行緒上,執行緒管理在使用者空間完成。此模式中,使用者級執行緒對作業系統不可見,即透明。

優點:這種模型的好處是執行緒上下文切換都發生在使用者空間,避免的模態切換(mode switch),從而對於效能有積極的影響。

多執行緒實現
【1】threading 模組

Python提供兩個模組進行多執行緒的操作,分別是threadthreading,前者是比較低階的模組,用於更底層的操作,一般應用級別的開發不常用。

import threading
import time


def spider01(timer):
    print("spider01 start")
    time.sleep(timer)  # 模擬IO
    print("spider01 end")


def spider02(timer):
    print("spider02 start")
    time.sleep(timer)  # 模擬IO
    print("spider02 end")


start = time.time()

# 建立執行緒物件

t1 = threading.Thread(target=spider01, args=(3,))
t1.start()
t2 = threading.Thread(target=spider02, args=(5,))
t2.start()

t1.join()
t2.join()

end = time.time()
print("cost time:", end - start)


spider01 start
spider02 start
spider01 end
spider02 end
cost time: 5.008162975311279

應用案例

import threading
import time
import requests
import re

def get_one_picture(url, n):
    res = requests.get(url)
    with open(f'./img/{n}.jpg', 'wb') as f:
        f.write(res.content)
        print(f'./img/{n}.jpg下載成功!')

start = time.time()
n = 1
domain = 'https://pic.netbian.com/'
for page in range(2, 7):
    res = requests.get(f'https://pic.netbian.com/4kmeinv/index_{page}.html')
    ret = re.findall('<img src="(/uploads/allimg/.*?)"', res.text)
    print(ret)
    for path in ret:
        url = domain + path
        # get_one_picture(url, n)
        t = threading.Thread(target=get_one_picture, args=(url, n))
        t.start()
        n += 1
end = time.time()
cost = end - start
t.join()
print(f"共計耗時{cost}秒!")
# 共計耗時26.590723991394043秒!
# 共計耗時6.7835376262664795秒!
【2】執行緒池

系統啟動一個新執行緒的成本是比較高的,因為他設計與作業系統的互動。在這種情況下,使用執行緒池可以很好的提升效能,尤其是當程式中需要建立大量生存期很短暫的執行緒時,更應該考慮使用執行緒池。

執行緒池在系統啟動時即建立大量空閒的執行緒,程式只要將一個函式提交給執行緒池,執行緒池就會啟動一個空閒的執行緒來執行它。當該函式執行結束後,該執行緒不會死亡,而是再次返回到執行緒池中變成空閒狀態,等待執行下一個函式。

此外,使用執行緒池可以有效地控制系統中的併發執行緒的數量。當系統中包含大量的併發執行緒時,會導致系統效能的急劇下降,甚至導致直譯器崩潰,而執行緒池的最大執行緒數引數可以控制系統中併發執行緒的數量不超過此數。

import time
from concurrent.futures import ThreadPoolExecutor


def task(i):
    print(f'任務{i}開始!')
    time.sleep(i)
    print(f'任務{i}結束!')
    return i


start = time.time()
pool = ThreadPoolExecutor(3)

future01 = pool.submit(task, 1)
print("future01是否結束", future01.done())
# print("future01的結果", future01.result())  # 同步等待
future02 = pool.submit(task, 2)
# print("future02的結果", future02.result())  # 同步等待
future03 = pool.submit(task, 3)
# print("future02的結果", future03.result())  # 同步等待
pool.shutdown()  # 阻塞等待
print(f"程式耗時{time.time() - start}秒鐘")

print("future01的結果", future01.result())
print("future02的結果", future02.result())
print("future03的結果", future03.result())


使用執行緒池來執行執行緒任務的步驟如下:

  1. 呼叫 ThreadPoolExecutor 類的構造器建立一個執行緒池。
  2. 定義一個普通的函式作為執行緒任務。
  3. 呼叫 ThreadPoolExecutor 物件的 submit() 方法來提交執行緒任務。
  4. 當不想提交任何任務時,呼叫 ThreadPoolExecutor 物件的 shutdown() 方法來關閉執行緒池。
【3】互斥鎖

併發程式設計中需要解決一些常見的問題,例如資源競爭和資料同步。由於多個執行緒或程序可以同時訪問共享資源,因此可能會導致資料不一致或錯誤的結果。weil了避免這種情況,需要採用合適的同步機制,如互斥鎖、訊號量或條件變數,來確保對共享資源的訪問是同步和有序的。

import time
import threading

Lock = threading.Lock()


def addNum():
    global num  # 在每個執行緒中都獲取這個全域性變數

    # 上鎖
    Lock.acquire()
    t = num - 1
    time.sleep(0.00001)
    num = t
    Lock.release()
    # 放鎖


num = 100  # 設定一個共享變數

thread_list = []

for i in range(100):
    t = threading.Thread(target=addNum)
    t.start()
    thread_list.append(t)

for t in thread_list:  # 等待所有執行緒執行完畢
    t.join()

print('Result: ', num)
【6】執行緒佇列

執行緒佇列是一種執行緒安全的資料結構,用於線上程之間傳遞和共享資料。它提供了一種解耦的方式,使生產者執行緒能夠將資料放入佇列,而消費者執行緒可以從佇列中獲取資料進行處理,從而實現執行緒之間的通訊和協調。

執行緒佇列的主要目的是解決多執行緒環境下的資料共享和同步問題。在多執行緒程式設計中,如果多個執行緒同時訪問共享資源,可能會導致資料的不一致性和競爭條件。透過使用執行緒佇列,可以避免直接訪問共享資源,而是透過佇列來傳遞資料,從而保證執行緒安全。

import queue

# 建立一個空的佇列
# q = queue.Queue()

# 建立具有固定大小的佇列
q = queue.Queue(3)

q.put(100)  # 將元素item放入佇列
q.put(200)
q.put(300)
# q.put(400)


print(q.get())
print(q.get())
print(q.get())
print(q.empty()) # 如果佇列為空,返回True;否則返回False
print(q.qsize())  # 返回佇列中的元素個數
print(q.get())


執行緒佇列還提供一些特性和機制,如阻塞和超時等待。當佇列為空時,消費者執行緒可以選擇阻塞等待新的資料被放入佇列,並且可以設定超時時間。這樣可以避免消費者執行緒空轉浪費資源,只有在有新的資料可用時才會繼續執行。

生產者-消費者模型

常見的執行緒佇列模型是生產者-消費者模型。生產者執行緒負責生成資料並將其放入佇列,而消費者執行緒則從佇列中獲取資料並進行處理。透過使用佇列作為緩衝區,生產者和消費者之間解耦,可以實現高效的執行緒間通訊。

import queue
import time
import threading

q = queue.Queue()


def producer():
    for i in range(1, 11):
        time.sleep(3)
        q.put(i)
        print(f"生產者生產資料{i}")

    print("生產者結束")


def consumer(name):
    while 1:
        val = q.get()
        print(f"消費者{name}消費資料:{val}")

        time.sleep(6)

        if val == 10:
            print("消費者結束")
            break


p = threading.Thread(target=producer)
p.start()
time.sleep(1)
c1 = threading.Thread(target=consumer, args=("消費執行緒1",))
c1.start()
c2 = threading.Thread(target=consumer, args=("消費執行緒2",))
c2.start()

執行緒佇列用於實現執行緒安全的資料傳遞和同步。它提供了一種簡單而高效的方式,讓多個執行緒能夠安全地共享和處理資料,從而提高程式的併發性和可靠性。

多程序實現

由於GIL的存在,python中的多執行緒其實並不是真正的多執行緒,如果想要充分的使用多核CPU的資源,在python中大部分情況需要使用多執行緒。

multiprocessing包是python中的多程序管理包。與threading.Thread類似,它可以利用multiprocessing.Process物件來建立一個程序。該程序可以執行在Python程式內部編寫的函式。該Process物件與Thread物件的用法相同,也有start(), run(), join()的方法。此外multiprocessing包中也有Lock/Event/Semaphore/Condition類 (這些物件可以像多執行緒那樣,透過引數傳遞給各個程序),用以同步程序,其用法與threading包中的同名類一致。所以,multiprocessing的很大一部份與threading使用同一套API,只不過換到了多程序的情境。

import multiprocessing
import threading
import time

def foo(x):
    ret = 1
    for i in range(x):
        ret += i
    print(ret)


start = time.time()
# (1) 序列版本
# foo(120000000)
# foo(120000000)
# foo(120000000)

# (2) 多執行緒版本
# t1 = threading.Thread(target=foo, args=(120000000,))
# t1.start()
# t2 = threading.Thread(target=foo, args=(120000000,))
# t2.start()
# t3 = threading.Thread(target=foo, args=(120000000,))
# t3.start()
#
# t1.join()
# t2.join()
# t3.join()

# end = time.time()
# print(end - start)

# (3) 多程序版本
if __name__ == '__main__':

    p1 = multiprocessing.Process(target=foo, args=(120000000,))
    p1.start()
    p2 = multiprocessing.Process(target=foo, args=(120000000,))
    p2.start()
    p3 = multiprocessing.Process(target=foo, args=(120000000,))
    p3.start()

    p1.join()
    p2.join()
    p3.join()

    end = time.time()
    print(end - start)

這個程式展示了三種不同的執行方式:序列版本、多執行緒版本和多程序版本,並統計了它們的執行時間。

  1. 序列版本:
    • 在序列版本中,foo(120000000)被連續呼叫了三次,以便計算累加和。
    • 這種方式是單執行緒執行的,每個呼叫都會阻塞其他呼叫的執行,直到計算完成並列印結果。
    • 執行時間是三次呼叫的總和。
  2. 多執行緒版本:
    • 在多執行緒版本中,使用了三個執行緒併發執行三次呼叫:t1 = threading.Thread(target=foo, args=(120000000,))
    • 每個執行緒獨立執行一次計算,並列印結果。
    • 由於全域性直譯器鎖(GIL)的存在,多執行緒並不能真正實現平行計算,因此在CPU密集型任務上可能無法獲得明顯的效能提升。
    • 執行時間是最長的單個執行緒的執行時間。
  3. 多程序版本:
    • 在多程序版本中,使用了三個程序併發執行三次呼叫:p1 = multiprocessing.Process(target=foo, args=(120000000,))
    • 每個程序獨立執行一次計算,並列印結果。
    • 多程序可以實現真正的平行計算,每個程序都在獨立的Python直譯器中執行,不受GIL的限制。
    • 執行時間是最長的單個程序的執行時間。
協程併發

Coroutine,協程是一種使用者態的輕量級執行緒。

協程擁有自己的暫存器上下文和棧。協程排程切換時,將暫存器上下文和棧儲存到其他地方,再切回來的時候,恢復先前儲存的暫存器上下文和棧。

協程能保留上一次呼叫時的狀態(即所有區域性狀態的特定組合),每次過程重入時,就相當於進入上一次呼叫的狀態,換種說法:進入上一次離開時所處邏輯流的位置。

【1】Greenlet庫

對標準庫中的yield關鍵字進行封裝的庫。允許在協程中使用yield語句來暫停和恢復執行,從而實現協程的功能。

在Greenlet中,協程中被稱為greenlet物件。我們可以建立一個greenlet物件,並使用switch方法來切換協程的執行。當一個協程暫停時,它的狀態會被儲存下來,可以在需要時恢復執行。

from greenlet import greenlet


def foo():
    print("foo step1")  # 第1步:輸出 foo step1
    gr_bar.switch()  # 第3步:切換到 bar 函式
    print("foo step2")  # 第6步:輸出 foo step2
    gr_bar.switch()  # 第7步:切換到 bar 函式,從上一次執行的位置繼續向後執行


def bar():
    print("bar step1")  # 第4步:輸出 bar step1
    gr_foo.switch()  # 第5步:切換到 foo 函式,從上一次執行的位置繼續向後執行
    print("bar step2")  # 第8步:輸出 bar step2


if __name__ == '__main__':
    gr_foo = greenlet(foo)
    gr_bar = greenlet(bar)
    gr_foo.switch()  # 第1步:去執行 foo 函式

# foo step1
# bar step1
# foo step2
# bar step2
# 注意:switch中也可以傳遞引數用於在切換執行時相互傳遞值。

Python Greenlet 提供了一種輕量級的協程實現方式,適合處理高併發和I/O密集型任務。其簡單易用的API和良好的相容性使其稱為Python開發者的理想選擇。

【2】asyncio模組

Asynchronous I/O是python一個用來處理併發(concurrent)事件的包,是很多python非同步架構的基礎,多用於處理高併發網路請求方面的問題。

為了簡化並更好的標識非同步IO,從Python 3.5開始引入了新的語法 async 和 await ,可以讓coroutine 的程式碼更簡潔易讀。

asyncio 被用作多個提供高效能Python非同步框架的基礎,包括網路和而網站服務,資料庫連線庫,分散式任務佇列等等。

asyncio 往往是構建IO 密集型和高層級 結構化 網路程式碼的最佳選擇。

async.create_task()建立task

async.gather()獲取返回值

async.run()執行協程

# 用gather()收集返回值

import asyncio, time


async def work(i, n):  # 使用async關鍵字定義非同步函式
    print('任務{}等待: {}秒'.format(i, n))
    await asyncio.sleep(n)  # 休眠一段時間
    print('任務{}在{}秒後返回結束執行'.format(i, n))
    return i + n


async def main():
    tasks = [asyncio.create_task(work(1, 1)),
             asyncio.create_task(work(2, 2)),
             asyncio.create_task(work(3, 3))]

    # 將task作為引數傳入gather,等非同步任務都結束後返回結果列表
    response = await asyncio.gather(tasks[0], tasks[1], tasks[2])
    print("非同步任務結果:", response)


start_time = time.time()  # 開始時間

asyncio.run(main())

print('執行時間: ', time.time() - start_time)

# 任務1等待: 1秒
# 任務2等待: 2秒
# 任務3等待: 3秒
# 任務1在1秒後返回結束執行
# 任務2在2秒後返回結束執行
# 任務3在3秒後返回結束執行
# 非同步任務結果: [2, 4, 6]
# 執行時間:  3.0192666053771973
【3】基於協程的非同步爬蟲應用

函式版:

import os.path
import threading
import time
import requests
import re


# 獲取頁圖片地址
def get_page_img_url(page):
    res = requests.get(f'https://pic.netbian.com/4kmeinv/index_{page}.html')
    ret = re.findall('<img src="(/uploads/allimg/.*?)"', res.text)
    return ret

# 下載頁圖片
def download_page_img(img_path):
    domain = 'https://pic.netbian.com/'
    for path in img_path:
        img_name = os.path.basename(path)
        url = domain + path
        get_one_picture(url,img_name)

# 下載一張圖片
def get_one_picture(url, n):
    res = requests.get(url)
    with open(f'./img/{n}', 'wb') as f:
        f.write(res.content)
        print(f'./img/{n}下載成功!')


start = time.time()
for page in range(2, 7):
    urls = get_page_img_url(page)
    download_page_img(urls)


end = time.time()
cost = end - start

print(f"共計耗時{cost}秒!")
# 共計耗時45.5535089969635秒!

最終版:

import os.path
import asyncio
import aiohttp
import time
import requests
import re


# 獲取頁圖片地址
async def get_page_img_url(page):
    # res = requests.get(f'https://pic.netbian.com/4kmeinv/index_{page}.html')

    async with aiohttp.ClientSession() as session:
        async with session.get(f'https://pic.netbian.com/4kmeinv/index_{page}.html', verify_ssl=False) as res:
            data = await res.content.read()
            ret = re.findall('<img src="(/uploads/allimg/.*?)"', data.decode('GBK'))
            return ret

# 下載頁圖片
async def download_page_img(img_path):
    domain = 'https://pic.netbian.com/'
    for path in img_path:
        img_name = os.path.basename(path)
        url = domain + path
        await get_one_picture(url, img_name)


# 下載一張圖片
async def get_one_picture(url, n):
    # res = requests.get(url)
    # with open(f'./img/{n}', 'wb') as f:
    #     f.write(res.content)
    async with aiohttp.ClientSession() as session:
        async with session.get(url, verify_ssl=False) as res:
            f = open(f"./img/{n}", 'wb')
            data = await res.content.read()
            f.write(data)
            f.close()
            print(f'./img/{n}下載成功!')


async def main():

    start = time.time()
    for page in range(2, 7):
        urls = await get_page_img_url(page)
        await download_page_img(urls)

    end = time.time()
    cost = end - start
    print(f"共計耗時{cost}秒!")
# 共計耗時45.5535089969635秒!


asyncio.run(main())
# 共計耗時28.672701358795166秒!

相關文章