- 動手打造深度學習框架
- 李偉
- 8543字
- 2022-04-26 18:05:29
前言
本書討論了如何將C++模板元編程(簡稱元編程)深入應用到一個相對較大的項目(深度學習框架)的開發過程中,通過元編程與編譯期計算為運行期優化提供更多的可能。
C++模板元編程與深度學習框架
本書內容將圍繞兩個主題展開:C++模板元編程與深度學習框架。在筆者看來,這兩個主題都算是時下比較前沿的技術。深度學習框架不必多說,它幾乎已經成為人工智能的代名詞,無論是在自然語言處理、語音識別,還是圖像識別等與人工智能相關的技術中,都可以看到深度學習的身影。本書的另一個主題——C++模板元編程,或者說與之相關的C++泛型編程,也是C++領域越來越熱門的一種技術。從C++11到C++20,我們看到標準中引入了越來越多的與 C++模板元編程相關的內容。C++20 中非常振奮人心的特性可能要數Concepts與std::ranges了,前者是直接對元編程語法的增強,后者則深入應用了元編程技術。可以說,正是元編程技術的發展,使得C++這門已經被使用了40余年的語言煥發出新的活力。
從泛型編程到元編程
C++并不容易上手。與Java等語言相比,它過于關注底層的機制,開發者需要人為地處理諸如內存的分配與釋放、對象的生命周期管理等“零零碎碎”的問題;與C語言、匯編語言相比,它又包含了過多的語法細節,學習成本要高很多。但C++也有它自身的優勢:它可以寫出性能堪比C語言程序的代碼,同時包含了足以用于構建大型程序的語法框架。這也讓它在眾多編程語言中脫穎而出,為很多開發者所鐘愛。
嚴格來說,幾乎所有的程序設計語言都是“圖靈完備”的,這也就意味著大家能做的事情差不多。之所以要發明出這么多程序設計語言,一個主要的原因就是要在易用性與高性能之間取得一種平衡。關于這種平衡,不同的程序設計語言選擇了不同的取舍方式:像Python、Java等語言更傾向于易用性,比如Python是弱類型的,我們可以使用一個變量名稱指代不同類型的數據;Java則通過虛擬機隱藏了不同計算機之間的硬件與操作系統的差異,實現了“一次編譯,到處運行”。相比之下,C++則將語言的天平更多地向性能傾斜,可以說,C++泛型編程與元編程正是這一點的體現。
舉例來說,同樣是構造容器保存數據,Python可以直接將數據放置到數組中,不需要考慮每個數據的具體類型。之所以可以這樣做,是因為Python中的每個類型都派生自一個相同的類型,所以數組中所保存的元素本質上都是這個類型的指針——這是一種典型的面向對象編程方式。C++也支持這種方式,但除此之外,C++還可以通過模板引入專門的容器,來保存特定類型的數據。事實證明,后一種方式由于對存儲的類型引入了更多的限制,因此有更多優化的空間,其性能也就更好。這種方式也被C++標準庫所采用。
通過模板,我們可以編寫一套相似的代碼,并以不同的類型進行實例化,從而實現對不同的類型進行相似的處理。這種可以應用于不同類型的代碼也被稱為“泛型”代碼。
在引入泛型機制的基礎上,又產生了一些新的問題。比如,我們可能需要根據某個類型來推導出相應的指針類型,以間接引用該類型的某個變量;又如,雖然大部分情況下,我們可以使用一套代碼來處理不同類型,但對于某些“特定的”類型來說,一些處理邏輯上的調整可能會極大地提升性能。因此,我們需要一種機制進行類型推導或邏輯調整。這種機制以程序作為輸入與輸出,是處理程序的程序,因此被稱為元程序,而相應的代碼編寫方法則被稱為元編程。
早期的元編程應用范圍相對有限,一方面是因為這種代碼的編寫方式難以掌握;另一方面則是因為C++語法對其支持程度不高。隨著人們對C++這門語言認識程度的加深,以及C++標準中引入了更多的相關工具,元編程的使用門檻也逐漸降低,以至于可以應用在很多復雜程序的開發之中。本書將元編程深入應用到深度學習框架的開發過程中,就是一次有益的嘗試。
深度學習框架中的元編程
深度學習框架中有一個核心概念——張量。張量可以被視為多維數組,典型的張量包括一維向量、二維矩陣等。矩陣可被視為一個二維數組,其中的每個元素是一個數值,可以通過指定行數與列數獲取該位置元素的值。
在一個相對復雜的系統中,可能涉及各種不同的矩陣。比如,在某些情況下我們可能需要引入某種數據類型來表示“元素全為0”的矩陣;或者一些情況可能需要基于某個矩陣表示出一個新的矩陣,新矩陣中的每個元素都是原有矩陣中相同位置元素乘 ?1 后的結果。
如果采用面向對象的方式,我們可以很容易地想到引入一個基類來表示矩陣,在此基礎上派生出若干具體的矩陣類型。比如:
1 class AbstractMatrix
2 {
3 public:
4 virtual int Value(int row, int column) = 0;
5 };
6
7 class Matrix : public AbstractMatrix;
8 class ZeroMatrix : public AbstractMatrix;
9 class NegMatrix : public AbstractMatrix;
AbstractMatrix定義了表示矩陣的基類,其中的Value接口在傳入行號與列號時,返回對應元素的值(這里假定它為int類型)。之后,我們引入了若干個派生類,使用Matrix來表示一般意義的矩陣;使用ZeroMatrix來表示元素全為0的矩陣;NegMatrix的內部則包含一個AbstractMatrix類型的對象指針,它表示的矩陣的每個元素為其中包含的Matrix對應元素乘?1的結果。
所有派生自AbstractMatrix的具體矩陣必須實現Value接口。比如,對ZeroMatrix來說,其Value接口的功能就是返回數值0;對NegMatrix來說,它會首先調用其內部對象的Value接口,之后將獲取的值乘?1并返回。
現在考慮一下,如果我們要構造一個函數,輸入兩個矩陣并計算二者之和,該怎么實現。基于前文所定義的類,矩陣相加函數可以使用如下聲明:
1 Matrix Add(const AbstractMatrix* mat1, const AbstractMatrix* mat2);
由于每個矩陣都實現了AbstractMatrix所定義的接口,因此我們可以在這個函數中分別遍歷兩個矩陣中的元素,將對應元素求和并保存在結果矩陣Matrix中返回。
顯然,這是一種相對通用的實現,能解決大部分問題,但對于一些特殊的情況則性能較差,比如:
- 如果一個Matrix對象與一個ZeroMatrix對象相加,那么直接返回Matrix對象即可;
- 如果一個Matrix對象與一個NegMatrix對象相加,同時我們能確保NegMatrix對象中,每個元素都是Matrix對象對應元素乘?1的結果,那么直接將結果矩陣中的每個元素賦值0即可。
為了在這類特殊情況中提升計算速度,我們可以在Add中引入動態類型轉換,來嘗試獲取參數所對應的實際數據類型:
1 Matrix Add(const AbstractMatrix* mat1, const AbstractMatrix* mat2)
2 {
3 if (auto ptr = dynamic_cast<const ZeroMatrix*>(mat1))
4 // 引入相應的處理
5 else if (...)
6 // 其他情況
7 }
這種設計有兩個問題:首先,大量的if會使函數變得非常復雜,難以維護;其次,調用Add時需要對if的結果進行判斷——這是一個涉及運行期的計算,引入過多的判斷,甚至可能使函數的運行速度變慢。
以上問題有一個很經典的解決方案:函數重載。比如,我們可以引入如下若干函數:
1 Matrix Add(const AbstractMatrix* mat1, const AbstractMatrix* mat2);
2 Matrix Add(const ZeroMatrix* mat1, const AbstractMatrix* mat2);
3 ...
4
5 ZeroMatrix m1;
6 Matrix m2;
7 Add(&m1, &m2); // 調用第二個優化算法
其中的第一個版本對應一般的情況,而其他版本則對應一些特殊的情況,為其提供相應的優化。
這種方案很常見,以至于我們可能意識不到這已經是在使用元編程了。我們相當于構造了一個元程序,其輸入是具體的矩陣類型,輸出是相應的求和算法。編譯器會根據不同的輸入選擇不同的算法處理——整個計算過程在編譯期完成。相應地,元編程也被稱為編譯期計算。
函數重載只是一種很簡單的編譯期計算——它雖然能夠解決一些問題,但使用范圍還是相對狹窄的。本書所要討論的則是更加復雜的編譯期計算方法:我們將使用模板來構造若干組件,其中顯式包含了需要編譯器處理的邏輯。編譯器使用這些模板推導出來的值(或類型)來優化系統。這種用于編譯期計算的模板被稱為“模板元函數”,而相應的計算方法也被稱為“元編程”或“C++模板元編程”。
元編程與大型程序設計
元編程并非一個新概念。事實上,早在1994年,埃爾溫·翁魯(Erwin Unruh)就展示了一個程序,其可以利用編譯期計算來輸出質數。但由于種種原因,對 C++模板元編程的研究一直處于不溫不火的狀態。雖然一度也出現過若干元編程的庫(比如Boost::MPL、Boost::Hana等),但應用這些庫來解決實際問題的案例相對較少。即使偶爾出現,這些元編程的庫與技術也往往處于一種輔助的地位,輔助面向對象的方法來構造程序。
隨著C++標準的發展,我們欣喜地發現,其中引入了大量的工具與語法,使得元編程越來越容易。這也使得使用元編程構造相對復雜的程序成為可能。
在本書中,我們將構造一個相對復雜的系統——深度學習框架。元編程在這個系統中不再是輔助地位,而是整個系統的“主角”。在前文中,我們提到了元編程與編譯期計算的優勢之一就是更好地利用運算本身的信息,提升系統性能。這里將概述如何在大型系統中實現這一點。
一個大型系統往往包含若干概念,每一種概念可能存在不同的實現,這些實現各有優勢。基于元編程,我們可以將同一概念所對應的不同實現組織成松散的結構。進一步可以通過使用標簽等方式對不同的概念分類,從而便于維護已有的概念、引入新的概念,或者引入已有概念的新實現。
概念可以進行組合。典型地,兩個矩陣相加可以構成新的矩陣。我們將討論元編程中的一項非常有用的技術:表達式模板。它用于組合已有的類型,形成新的類型。新的類型中保留了原有類型中的全部信息,可以在編譯期利用這些信息進行優化。
元編程的計算是在編譯期進行的。深入使用元編程技術,一個隨之而來的問題就是編譯期與運行期該如何交互。通常來說,為了在高效性與可維護性之間取得平衡,我們必須考慮哪些計算是可以在編譯期完成的、哪些則最好放在運行期、二者如何過渡。在深度學習框架實現的過程中,我們會看到大量編譯期與運行期交互的例子。
編譯期計算并非目的,而是手段:我們希望通過編譯期計算來改善運行期性能。讀者將在本書的第10章看到如何基于已有的編譯期計算結果,來優化深度學習框架的性能。
目標讀者與閱讀建議
本書將使用編譯期計算與元編程打造一個深度學習框架。深度學習是當前研究的一個熱點領域,以人工神經網絡作為核心,包含了大量的技術與學術成果。本書主要討論元編程與編譯期計算的方法,因此并不考慮做一個大而全的工具包。但我們所打造的深度學習框架是可擴展的,能夠用于人工神經網絡的訓練與預測。
盡管對討論的范圍進行了上述限定,但本書畢竟同時涉及元編程與深度學習,讀者如果沒有一定的背景知識很難完成學習。因此,我們假定讀者對相關數學知識與C++都有一定的了解,具體有以下幾點。
- 讀者需要對C++面向對象的開發技術、模板有一定的了解。本書并不是C++入門書,如果讀者想了解C++的入門知識以及C++標準的有關內容,可以參考《C++ Primer Plus 第6版 中文版》或者其他類似的書籍。
- 讀者需要對線性代數的基本概念有所了解,知道矩陣、向量、矩陣乘法等概念。人工神經網絡的許多操作都可以抽象為矩陣運算,因此基本的線性代數知識是不可缺少的。
- 讀者需要對高等數學中的微積分與導數的概念有基本的了解。梯度是微積分中的一個基本概念,在深度學習的訓練過程中占據非常重要的地位——深度學習中的很大一部分操作就是梯度的傳播與計算。雖然本書不會涉及微積分中很高深的知識,但要求讀者了解偏導數?y/?x的基本含義。
元編程的成本
使用元編程可以寫出靈活高效的代碼,但這并非沒有代價。本書將集中討論元編程,但在此之前有必要明確使用元編程的成本,從而對這項技術有更加全面的認識。
元編程的成本主要由兩個方面構成:研發成本與使用成本。
1.研發成本
從本質上來說,元編程的研發成本并非來自這項技術本身,而是來自開發者編寫代碼的習慣轉換所產生的成本。雖然本書討論的是C++中的一項編程技術,但它與面向對象的C++開發技術有很大區別。從某種意義上來說,元編程更像一門可以與面向對象的 C++代碼無縫銜接的新語言,想掌握并用好它還是要花費一些力氣的。
對熟悉面向對象的C++開發者來說,學習并掌握這項新的編程技術,主要的難點在于建立函數式編程的思維模式。編譯期涉及的所有元編程方法都是函數式的,構造的中間結果無法改變,由此產生的影響可能會比想象中要大一些。本書將會通過大量的示例來幫助讀者逐步建立這樣的思維模式。相信讀完本書,讀者會對其有相對深入的認識。
使用元編程的另一個問題是調試困難。原因也很簡單:大部分C++開發者都在使用面向對象的方式編程,因此大部分編譯器都會針對這一點進行優化。相應地,編譯器在輸出元編程的調試信息方面效果就會差很多。很多情況下,編譯器輸出的元程序錯誤信息更像是一篇短文。這個問題在C++20標準引入了Concepts后有所緩解,但目前主流的編譯器還是支持C++17標準,對該問題沒有什么特別好的解決方案。通常來說,我們要多動手做實驗,多看編譯器的輸出信息,慢慢找到感覺。
還有一個問題:相對使用面向對象的C++開發者來說,使用元編程的開發者畢竟算是“小眾”。這就造成了在多人協作開發時,使用元編程比較困難——因為別人看不懂你的代碼,所以其學習與維護成本會比較高。筆者在工作中就經常遇到這樣的問題,事實上也正是這個問題間接促進了本書的面世。如果你希望說服你的協作者使用元編程開發C++程序,可以向他推薦本書。
2.使用成本
元編程的研發成本是一種主觀成本,可以通過開發者提升自身的編程水平來降低。相對地,元編程的使用成本則是一種客觀成本,處理起來更棘手。
通常情況下,如果我們希望開發一個程序包并交付他人使用,那么程序包中往往會包含頭文件與編譯好的靜態庫或動態庫,程序的主體邏輯是位于靜態庫或動態庫中的。這樣有兩個好處:首先,程序包的提供者不必擔心位于靜態庫或動態庫中的主體邏輯遭到泄漏——使用者無法看到源碼,要想獲得程序包中的主體邏輯,需要通過逆向工程等手段實現,成本相對較高;其次,程序包的使用者可以較快地進行自身程序的編譯并鏈接——因為程序包中的靜態庫、動態庫都是已經編譯好的,這一部分代碼的編譯過程會被省略。
但如果我們使用元編程開發一個程序包并交付他人使用,那么通常來說將無法獲得上述兩個好處:首先,元編程的邏輯往往是在模板中實現的,而模板是要放在頭文件中的,這就造成元程序包的主體邏輯源代碼在頭文件中,其會隨著程序包的發布提供給使用者,使用者了解并仿制相應邏輯的成本會大大降低;其次,調用元程序庫的程序在每次編譯過程中都需要編譯頭文件中的相應邏輯,這就會增加編譯的時間。
如果我們無法承擔由元編程所引入的使用成本,就要考慮一些折中的解決方案了。一種典型的方案是對程序包的邏輯進行拆分,將編譯耗時長、不希望泄漏的邏輯先行編譯,形成靜態庫或動態庫,同時將編譯時長較短、可以展示源代碼的部分使用元程序編寫,以頭文件的形式提供,從而確保依舊可以利用元程序的優勢。至于如何劃分,則要視項目的具體情況而定。
本書的組織結構
本書包含兩部分。第1部分(第1~3章)將討論元編程的基礎技術,這些技術將被用在第2部分(第4~10章)中,用于打造深度學習框架。
第1章 元編程基本方法。本章討論元函數的基本概念,討論將模板作為容器的可能性,在此基礎上給出順序、分支、循環代碼的編寫方法——這些方法構成整個編程體系的核心。在此之后,我們會進一步討論一些典型的慣用法,包括奇特的遞歸模板式(Curiously Recurring Template Pattern,CRTP)等內容,它們都會在后文中被用到。
第2章 元數據結構與算法。本章在第1章的基礎上進行了引伸,引入基本的數據結構與算法的概念。我們將討論在編譯期表示集合、映射(map)等數據結構的方法,同時給出編譯期高效的數據索引與變換算法。這些算法都是泛型的,但與傳統的C++泛型算法不同。傳統的C++泛型算法的目的是處理運行期不同的數據,而這里的算法是為了處理編譯期不同的數據(甚至是類型)。我們將這種高效處理的算法抽象出來,保存在一個元算法庫中,供后續編寫深度學習框架使用。
第3章 異類詞典與policy模板。本章將會利用前兩章的知識構造出兩個組件。第1個組件是一個容器,用于保存不同類型的數據對象;第2個組件則是一個使用具名參數的policy 系統。這兩個組件均將用于后續深度學習框架的打造。雖然本章偏重于基礎技術的應用,但筆者還是將其歸納為泛型編程的基礎技術,因為這兩個組件都比較基礎,可以作為基礎組件應用于其他項目之中。這兩個組件本身不涉及深度學習的相關知識,但我們會在后續打造深度學習框架時使用它們來輔助設計。
第4章 深度學習概述。從本章開始,我們將著手打造深度學習框架。本章將介紹深度學習框架的背景知識。如果讀者之前沒有接觸過深度學習,那么通過閱讀本章,可以對這一領域有一個大致的了解,從而明晰我們要打造的框架所要包含的主要功能。
第5章 類型體系與基本數據類型。本章討論深度學習框架所涉及的數據。為了最大限度地發揮編譯期計算的優勢,我們將深度學習框架設計為富類型的,即它能夠支持很多具體的數據類型。隨著所支持數據類型的增多,如何有效地組織這些數據類型就成了一個重要的問題。本章討論基于標簽的數據類型組織形式,它是元編程中一種常見的分類方法。
第6章 運算與表達式模板。本章討論深度學習框架中運算的設計。人工神經網絡會涉及很多運算,包括矩陣相乘、矩陣相加、取元素對數值,以及更復雜的運算。為了能夠在后續對運算進行優化,這里采用了表達式模板以及緩式求值的技術。
第7章 基本層。在運算的基礎上,我們引入了層的概念。層將深度學習框架中相關的操作關聯到一起,提供了正向、反向傳播的接口,便于用戶調用。本章將討論基本層,描述如何使用第3章所構造的異類詞典和policy模板來簡化層的接口與設計。
第8章 復合層。基于第7章的知識,我們就可以構造各式各樣的層,并使用這些層來搭建人工神經網絡。但這種做法有一個問題:人工神經網絡中的層是千變萬化的,如果每一個之前沒有出現過的層都手工編寫代碼實現,那么工作量還是比較大的,也不是我們希望看到的。在本章我們將構造一個特殊的層——復合層,用于組合其他的層來產生新的層。復合層中比較復雜的一塊邏輯是自動梯度計算——這是人工神經網絡在訓練過程中的一個重要概念。可以說,如果無法實現自動梯度計算,那么復合層存在的意義將大打折扣。本章將會討論自動梯度計算的一種實現方式,它也是本書的重點之一。
第9章 循環層。循環層的特殊之處在于需要對輸入數據進行拆分,對拆分后的數據依次執行正向、反向傳播邏輯,并將執行后的結果進行合并。我們將循環層的通用邏輯與具體的正向、反向傳播算法分離出來,從而實現一個相對靈活的循環層組件。
第10章 求值與優化。人工神經網絡是一種計算密集型的系統,無論對訓練還是預測來說都是如此。一方面,我們可以采用多種方式來提升計算速度,典型地,可以使用批量計算同時處理多組數據,最大限度地利用計算機的處理能力;另一方面,我們可以對求值過程進行優化,從數學意義上簡化與合并多個計算過程,從而提升計算速度。本章將討論與此相關的主題。
源代碼與編譯環境
本書是元編程的實戰型圖書,很多理論也是通過示例的方式進行闡述的。不可避免地,本書會涉及大量代碼。我盡量避免將本書做成代碼的堆砌(這是在浪費讀者的時間與金錢),盡量做到書中只引用需要討論的核心代碼與邏輯,完整的代碼則在隨書源碼中給出。
讀者可以在https://github.com/liwei-cpp/MetaNN/tree/book_v2中下載本書的源碼。源碼中包含幾個子目錄。其中,MetaNN 子目錄包含了深度學習框架中的全部邏輯,而其他子目錄中則是一些測試邏輯,用來驗證框架邏輯的正確性。本書所討論的內容可以在MetaNN目錄中找到對應的源碼。閱讀本書時,有一份可以參考的源碼以便隨時查閱是非常重要的。本書用了較多的篇幅闡述設計思想,但只是羅列了一些核心代碼。因此,筆者強烈建議讀者對照源代碼來閱讀本書,這樣能對本書討論的內容有更加深入的理解。
對于MetaNN中實現的每個技術要點,我們都引入了相應的測試用例。因此,讀者可以在了解了某個技術要點的實現細節之后,通過閱讀測試用例,進一步體會相應技術要點的使用方法。
MetaNN中的內容全部是頭文件,測試用例則包含了一些CPP文件,可以編譯成可執行程序。MetaNN中的代碼主要基于C++17編寫。因此,測試代碼的編譯器需要支持C++17標準。同時需要注意的是,由于代碼中使用了大量的元編程技術,因此會給編譯過程帶來不小的負擔。特別地,編譯所需要的內存相對較多。因此,這里不建議采用32位編譯器進行編譯,否則可能會出現因編譯器內存溢出而編譯失敗的情況。
筆者采用Linux系統,以GCC與Clang作為測試編譯器,在GCC 、Clang 8.0.0等環境中完成編譯測試。代碼使用CodeLite工程進行組織,讀者可以在Linux系統中安裝CodeLite,導入代碼中的MetaNN.workspace工程文件進行編譯,也可以嘗試使用自己熟悉的工具編譯代碼[1]。
本書代碼的格式
筆者盡量避免在討論技術細節時羅列很多非核心的代碼。同時,為了便于討論,通常來說代碼的每一行前面會包含一個行號:在對該代碼進行分析時,一些情況下會使用行號來表示具體行的代碼,說明該行所實現的功能。
行號只是為了便于后續的代碼分析,并不表明該代碼在源代碼文件中的位置。一種典型的情況是,當要分析的核心代碼比較長時,代碼的展示與分析是交替進行的。此時,展示的每一段代碼將均從行號1開始標記,即使當前展示的代碼與上一段展示的代碼存在先后關系,也是如此。如果讀者希望閱讀完整的代碼,明確代碼的先后關系,可以閱讀隨書源碼。
關于練習
除了第4章外,每一章的最后都包含了若干題目,用于讀者鞏固學到的知識。這些題目并不簡單,有些也沒有所謂標準答案。因此,如果讀者在練習的過程中遇到了困難,請不要灰心,你可以選擇繼續閱讀后文,在熟練掌握了本書所要傳達的一些技巧后,回顧之前的題目,可能就迎刃而解了。再次聲明,一些題目本就是開放性的,沒有標準答案。因此如果做不出來,或者你的答案與別人的不同,請不要灰心。
反饋
由于筆者水平有限,而元編程又是一個比較有挑戰性的領域,因此本書難免出現疏漏。對于本書描述隱晦不清之處以及其他可以改進的地方,歡迎發郵件到liwei.cpp@gmail.com與筆者交流。
李偉
2021年12月
[1] 需要說明的是,雖然很多編譯器都支持C++17,但在支持的細節上有所差異。筆者嘗試使用Visual Studio 2019編譯測試代碼,但有部分代碼無法通過編譯,系統提示編譯器內部錯誤。
- Apache Oozie Essentials
- 編程的修煉
- Java EE框架整合開發入門到實戰:Spring+Spring MVC+MyBatis(微課版)
- 少年輕松趣編程:用Scratch創作自己的小游戲
- 算法基礎:打開程序設計之門
- WSO2 Developer’s Guide
- Python算法指南:程序員經典算法分析與實現
- 開源項目成功之道
- Kubernetes進階實戰
- C指針原理揭秘:基于底層實現機制
- RESTful Web Clients:基于超媒體的可復用客戶端
- 從程序員角度學習數據庫技術(藍橋杯軟件大賽培訓教材-Java方向)
- 深入淺出Python數據分析
- Learning Jakarta Struts 1.2: a concise and practical tutorial
- Python網絡爬蟲實例教程(視頻講解版)