C++20 - 下一个大版本功能确定
C++20的功能特性已經于3月份凍結,顯然這次終于來了一波大的改進,而不再是像之前C++14/C++17那般小打小鬧的做小步快跑,尤其是三個討論很久的大feature終于被合入主干;并且這些feature終將會極大地影響后續C++代碼的書寫方式。
核心語言特性終于有了大變化
新的版本之所以被認為是下一個大的版本,主要原因還是來自于核心語言特性的擴充和簡化。看起來好像兩個目標有些互相矛盾,但是內在的邏輯其實還是統一的:
- 擴充新的特性可以彌補之前一些遺留已久的功能限制,方便提高程序員的生產力,減少社區中長期存在的奇技淫巧侵蝕程序員寶貴的心智空間;
- 簡化的方向主要是出于“照顧”新手程序員,幫助他們更快地上手產生生產力而不是匍匐在陡峭的學習曲線上靠長期的實踐積累來摸索,從而培養下一代的新鮮血液,否則語言就會因為失去活力而慢慢消亡;這顯然不是標準委員會愿意看到的。
從這兩個角度看,也許片面地評價標準委員會的資深專家們為“學院派”或者“老學究”,總歸是有些不合適的;因為C++一開始在90年代上半段的風靡完全是因為它是一門實用的程序語言。 只是隨著時間的推進,很多早期做出的設計決策多多少少被整個產業界的各色各樣的業務需求催生的奇技淫巧所侵蝕;
尤其是模板元編程的流行和語言特性本身的滯后帶來的矛盾一直沒有得到合適的處理,背后的原因正是標準委員會需要照顧已有的軟件代碼的兼容性(當然背后也有很多大公司的利益考量),妥協再妥協;最終演變成不得不變的地步。
Concepts
像concept這樣可以明顯提升程序員生活質量的特性(想象一下用錯了一個容器的成員函數之后GCC打印出來的”成噸”的編譯錯誤,很多程序員形容是恨不得捏著鼻子繞著走),愣是從C++03定稿之后就被提出出來,卻活生生被推遲了一次又一次,甚至GCC的版本庫上的concepts分支都經歷了加上來又移除掉的曲折過程 - 速度和質量始終是一對很難權衡的矛盾。
幸好,經歷了十幾年的再三討論,concept這一模板元大殺器終于被投票送進了C++20標準的正式列表里。
關于Concepts最好的介紹當然是Bjarne自己的這篇Concepts: The Future of Generic Programming的文章, 另外一個比較好的描述來自于cppreference;簡單來說,它完成的事情就是用來描述泛型定義中,關于類型參數的約束和校驗; 出于零成本的考慮,我們需要做到這個校驗可以
- 在編譯期間完成檢查,對生成的實際代碼沒有影響(就像手寫的代碼一樣)
- 具備定義良好的接口形式,可以方便地進行組合
- 盡量地保持通用性
使用Concepts
通過使用concepts,傳統的模板元編程方面關于編譯錯誤的痛點可以得到極大改善,編譯器可以給出更加符合人類直覺的錯誤提示。 比如標準庫中的std::find算法的聲明如下:
template< class InputIt, class T > InputIt find( InputIt first, InputIt last, const T& value );這里的兩個模板參數其實有更多額外的要求用傳統的語法是沒法表達的,第一個類型參數Input我們期望它是一個可遍歷的迭代器類型,并且其中的元素類型需要和T類型匹配,并且該類型能夠用來做相等比較。 這些約束條件在現有的語言標準中都是隱性的,一旦用錯,編譯器就會拿海量的錯誤信息來招呼你,因為編譯器背后會使用SFINAE這樣的語言特性來比較各種重載并給出一個常常的candidate列表,然后告訴你任何一個嘗試都沒有成功,所以失敗了。
Concepts相當于將這些要求用一種顯而易見的方式給出來,比如我們想表述一個在序列容器上查找的類似算法,可以用concepts來描述為
template <typename S, typename T> requires Sequence<S> && Equality_comparable<Value_type<S>, T> Iterator_of<S> find(S& seq, const T& value);//using alias template<typename X> using Value_type<X> = X::value_type; template<typename X> using Iterator_of<X> = X::iterator;這時候如果使用不滿足條件的輸入參數,編譯器會直觀地告訴我們錯誤的具體原因
vector<string> vs; list<double> list; auto p0 = find(vs, "waldo"); //okay auto p1 = find(vs, 0.11); //error! - can't compare string and double auto p2 = find(list, 0.5); //okay auto p3 = find(list, "waldo"); //error! - can't compare double and string顯然這里的例子有點啰嗦,出于節約鍵盤敲擊次數的考慮(Java太啰嗦了?原來的模板元函數的寫法也已經夠啰嗦的了!),可以進一步簡化這個寫法,將簡單的concepts約束直接嵌入到聲明的地方:
template <Sequence S, typename T> requires Equality_comparable<Value_type<S>, T> Iterator_of<S> find(S& seq, const T& value);自定義concepts
對于上面的簡單的concepts,標準庫已經提供了一個開箱可用的封裝,不過出于學習目的自己動手做一個輪子也很簡單。比如用上面的比較為例,可以寫作
template <typename T> concept bool Equality_comparable = requires(T a, T b) {{ a == b} => bool; //compar with ==, and should return a bool{ a != b} => bool; //compare with !=, and should return a bool }語法上和定義一個模板元函數很想象,所不同的地方是
- 這里我們定義的對象是一個關于類型的檢查約束
- 這里的requires部分引申出具體的檢查約束,必須同時實現操作符相等和不相等,兩個操作符都需要返回bool類型
- 整個concept本身可以用在邏輯表達式中
簡化concept的格式負擔
如果能將簡單的事情變得更簡單,為什么不更進一步呢?這個設計哲學是C++的核心設計思想之一(參見Bjarne的D&E CPP),考慮下面的例子
template <typename Seq>requires Sortable<Seq> void sort(Seq& seq);這里的Sortable表示某個可以被排序的容器類型;因為concept也是用于限制類型,而函數的參數也是用來限定類型,一個自然的想法就是逐步簡化它
//應用上述的簡化方式,concept描述放在模板參數聲明中 template <Sortable Seq> void sort(Seq& seq);進一步地,去掉template部分的聲明會更加簡單,就像是一個普通的函數聲明,只不過參數類型是一個編譯器可以檢查的泛型類型
void sort(Sortable& s);這樣一來,就和其它語言中的接口很類似了,沒錯就和Java的JDK中的泛型接口很相似了;只是底層的實現技術是完全不一樣的,Java由于根深蒂固的OO設計而不得不借助于類型擦除術;當然這個扯的稍微遠了一點。
auto類型的參數
其實C++14里面已經允許了auto作為函數參數的類型這一用法,顯然它和concept的簡化寫法完全不矛盾。
void func(auto x); // x實際上可以是任意類型! void func1(auto x, auto y); //x和y可以是任意的類型,可以不相同在多個參數的情況下, 如果我們想限定兩個參數的類型必須總是一樣,有一種很簡單的機巧做到
constexpr concept bool Any = true; //任何類型都是Any void func(Any x, Any y); //x和y的類型必須相同,盡管他們可以是任意類型標準庫中的預定義concepts
C++20的標準庫中預備了很多開箱即用的concepts,通過庫的方式提供,用戶只需要包含<concepts>庫即可。 詳細的列表可以參考concepts header;從大的分來來看,包括
應該可以預期后續的版本將會加入更多的支持。
編譯器的支持情況
GCC目前仍然是通過TS的方式來支持,編譯時候需要加上-fconcepts開關; Clang的全功能支持已經在將近一年前在redit上宣布完工,只是其官方的列表上依然沒有更新。 MSVC則于更早一點宣布了完整的concept支持,只是我們需要Visual Studio 2017 15.7版本。
The MSVC compiler toolset in Visual Studio version 15.7 conforms with the C++ Standard!
https://devblogs.microsoft.com/cppblog/announcing-msvc-conforms-to-the-c-standard/
總體上看來,GCC的開發進度有些遲緩,clange的也不算很透明,只有MSVC比較領先。
模塊化支持
模塊支持被寫入新的語言核心,這一新的封裝方式甚至可以認為是C++誕生35年以來最大的一個新功能; 也是語言標準化以來,第一次通過修改核心語法允許程序員用一種全新的方式來描述帶命名的信息封裝邊界。
信息封裝手法的更新
傳統的封裝手段基本上都是采用如下的方式:
- 將用戶自定義的結構或者類取一個名字
- 將相關聯的細節都隱藏在這個名字的后面
不管是變量聲明,函數定義,自定義的類,結構體,無一例外都滿足這個模式。即使是模板元編程方法,其實也是通過類型綁定的方法間接地使用上述的封裝手段。
頭文件的不完美封裝
除了上述的基本信息封裝單元,C++中屈指可數的封裝辦法就剩下了從古老的C語言繼承下來的頭文件包含的方式了。 在軟件規模還局限在數萬行代碼一下的時代,使用頭文件的方式一股腦將需要的東西都大包大攬在一個編譯單元中,然后使用諸如唯一定義規則的方法讓鏈接器在生成最終可執行代碼或者庫的時候做沖突檢測是一個簡單而優雅的方案。 因為對于它想解決的問題規模來說,這樣的解決方案就足夠了。
然而隨著行業中軟件項目的復雜性與日俱增,越來越多的商業項目需要數百甚至上千的頭文件被包含在一個編譯單元中,這個時候既有的方式就越來越捉襟見肘
簡單來看,現代的編程語言都或多或少帶有模塊化系統;缺乏現代的模塊化支持成為了C++語言的一種硬傷,嚴重制約了C++開發大項目的能力。
模塊化系統需要的核心功能
模塊化是一個很自然的邏輯信息隱藏手段,一個良好的模塊化系統應該允許
要同時實現這些目標,并沒有想象中的容易;其它一些流行的編程語言其實都小心仔細地對這些可能“討好”程序員的目標做取舍,并在定義中詳細地描述好限制。 比如Java一開始用Jar打包的方式來模擬模塊,但是卻由于不支持嵌套子模塊中復雜的訪問控制而遭到很多用戶的不滿;而Go語言中的模塊和文件系統中文件名的糾葛同樣也是Go語言中一個晦澀的知識點。 NodeJS通過NPM機制來提供模塊化支持,然而其嵌套的打包方式和讓人窒息的依賴樹結構導致打包的時候需要依賴其它的第三方工具才能避免中招。
后向兼容的艱難挑戰
C++的模塊機制是奔著替換舊有的頭文件包含機制的目標來的,同時又因為需要照顧龐大的既有代碼庫不被破壞而不得不同時兼容頭文件包含機制。 和已有的其它語言特性一樣,這種向后兼容帶來的額外復雜性是否是必要的還又不小不同的聲音,不過主流的聲音還是決定走兼容的道路。
基本語法
如果我們希望聲明一個模塊,可以用如下的語法
export module example; //聲明一個模塊名字為example export int add(int first, int second) { //可以導出的函數return first + second; }因為我們丟棄了頭文件的方法,可以將該模塊定義保存在example.cppm的文件中。這里的cppm后綴用于告訴編譯器這是一個模塊定義文件。
假設我們希望使用該模塊,則用如下的代碼
import example; //導入上述定義的模塊 int main() {add(1, 2); //調用example模塊中的函數 }分離模塊接口和實現
如果我們想分離模塊的聲明和實現,將他們放在不同的文件中,這樣更符合傳統的接口定義和實現分離的編寫代碼方法(其實可以看作是C++比Java更干凈的一個地方),我們可以對上面的example.cppm做如下的修改
export module example; extern int add(int first, int second);然后創建一個源代碼文件,放置模塊函數的實現
module example; //當前模塊是example int add(int first, int second) {return first + second; }出于靈活起見,C++20支持將一個模塊中聲明的函數放在多個模塊實現單元中分別實現,這樣更容易實現干凈的代碼,并提高編譯速度。
隔離權限指定
模塊訪問權是通過export聲明來指定的,沒有聲明的類或者函數等默認是不能被外部代碼訪問的;基于聲明的語法也決定了如果分離聲明和實現,可見性在實現單元中其實是忽略的。
為了避免代碼變得過于啰嗦,語法層面上也支持通過括號作用于一次性聲明多個導出函數或者類,比如
export module example; export {void doSth();int doAnother(auto x, auto y); } void internalImpl(); //外部不可訪問模塊和namespace是正交的語言設施
舊的C++標準早就支持通過namespace來實現信息封裝和隔離,而新的module機制可以和namespace結合使用,提供清晰的隔離結構,比如
export module example; export namespace name {void doSth();int doAnother(auto x, auto y); }語言機制上提供了靈活的手段,但是程序員卻要自己做好權衡,保持模塊的粒度適中,匹配實際的應用場景。
模塊重新導出
實際應用中,復雜的軟件項目可能有很多形形色色的模塊,它們可能處于不同的抽象單元;和應用代碼比較近的上層模塊可能需要將某些它自己可見的模塊開放給上層代碼直接使用,提供重新導出的功能可以極大地提高信息封裝的能力,提高模塊的內聚度減少不必要的耦合。
一個簡單的方法就是將import的部分重新放在export塊中,即下面的代碼例子
export module mid; export {import low_module1;import low_module2;void myFunc(auto x); }標準庫中的模塊
標準庫中提供的工具函數和類顯然應該被模塊化,只需要使用import std.xxx即可導入。 現代的WG21委員會的工作方式是有很多并行開發但是還沒有進入主干庫的”準標準庫”,編譯器可以選擇實現,等到對應的規范成熟的時候,它們才會被正式地移入標準庫中。
Visual C++的封裝方式如下
潛在的爭議?
作為一門有著30多年歷史的語言,模塊化機制的一個設計難點就是保持和古老的include機制(本質上是代碼的復制)兼容該如何實現。 好在WG21經過漫長的討論終于實現了起碼在理論上完美的兼容 - 用戶可以自由混用兩者,只要不產生重復和鏈接問題即可。 Redit的cpp頻道里面有人發起了一個是否提供一種機制讓用戶強制在某一個模塊中清理舊有的include模式的討論,采用的思路正式類似Rust語言的版本指定的思路。
這個想法其實有很重要的現實意義,因此有很多自身CPP用戶發表了自己的看法,大概標準定義成現在這個樣子應該主要是兩個方面的原因
這些問題其實都是很現實的問題,個人覺得WG21選擇向后兼容的思路并沒有什么問題,因為從新發明輪子的時候都是簡單的,真正復雜的是如何長期穩定地維護和更新。 C++的使用領域一直在縮小(或者有人說它是退回到了適合的領域)是個不爭的事實,然而在適合的領域,它的優勢不光在于語言本身還依賴于這些遺留系統的支撐。
協程支持
協程并不是一個新鮮的概念,甚至在現代編程語言出現之前很久就被提出出來,并在其它一些編程語言中被實現了很長時間了;它的基本思想是要求一個函數或者過程可以在執行過程中被操作系統或者調度器臨時中止,然后在適合的時機(獲取CPU計算資源等)再被恢復執行。詳細的描述可以參考這里。
為什么需要協程
協程最明顯的一個好處是允許我們書寫看起來順序執行但是其實背后卻異步執行的代碼,這樣技能協調人大腦擅長順序邏輯和計算機處理擅長異步執行的矛盾,兼顧效率和心智負擔。 同時協程還可以支持惰性賦值和初始化的邏輯,進一步提高程序的運行效率(僅僅在需要的時候做運算)但是又不對程序員的大腦產生太多的額外負擔。
協程是一個比進程和線程更輕量級一點的概念,具體實現上來說可以分為有棧協程和無棧協程;技術上來說前者可以通過第三方庫實現就可以,但是性能開銷比較大也容易出問題;而無棧協程更加輕量級但是需要語言特性上做出改動。
C++20引入的協程屬于無棧協程。
基本語法定義
C++中的協程首先要是一個函數,它滿足如下特性
協程函數語法和關鍵字
協程函數定義可以又如下一些特征:
co_await操作符等待另外一個協程的完成
比如如下的從網絡讀取數據并寫回對方的echo代碼,從邏輯上看循環內部的兩行代碼是順序執行的,但是co_awit關鍵字卻標明了邏輯上它是通過”等待“另外一個協程完成才繼續往下執行的。
task<> tcp_echo_server() {char data[1024];for (;;) {size_t n = co_await socket.async_read_some(buffer(data));co_await async_write(socket, buffer(data, n));} }co_yield 可以直接掛起當前的協程執行并返回一個值
比如下面的循環中,每次到yield操作的時候,當前的協程便被暫時中止執行并返回一個整數
generator<int> iota(int n = 0) {while(true)co_yield n++; }這種用法在其它語言中也叫generator函數或者生成器。
co_return用于直接返回
lazy<int> f() {co_return 7; }返回類型要求
因為協程的返回值并不是普通的值而是一個可以和另外一個協程相互協作的對象,因此C++標準對協程的返回值有如下要求:
- 不能使用可變參數
- 不能使用普通的return語句
- 不能返回自動推導的類型,如auto或者concept類型等
同時如下的函數也不能是協程
- constexpr函數因為需要在編譯器運算,不能是協程
- 構造函數和析構函數用于普通對象的構造,也不能被延遲執行進而不能是協程
- 主函數不能是協程,否則操作系統無從啟動程序
協程的執行
任何一個協程其實由如下這些要素構成
- 保存內嵌的promise對象
- 用值拷貝方法傳遞的參數值對象 - 顯然出于內存安全的考慮不能由引用或者指針
- 當前執行到哪個階段的狀態標識,從而外部協程知道下一步是否應該遷移狀態還是需要銷毀幀數據
- 其它一些生存期超越當前掛起點的局部變量
協程執行的流程
當一個協程執行的時候,它的實際運行順序如下
當該協程函數執行到一個掛起點,返回對象將會通過必要的類型轉換返回給外部協程的等待方。
返回
如果協程函數執行到一個co_return語句,則執行如下的操作
- co_return;
- co_return expr; 但是expr的類型其實是void
- 直接跳過了可能的co_return語句而執行到了函數的結果;如果promise對象恰好定義了Promise::return_void()函數,那么行為就是未定義的,需要格外留意!
協程異常處理
如果協程中拋出了未捕獲異常,它的行為如下
狀態對象的銷毀
當協程狀態對象因為co_return或者異常情況需要銷毀的時候,其執行過程如下
堆內存分配
協程的狀態必須要通過operator new來分配,因為標準要求這里必須是無棧協程。分配過程遵循如下兩條規則
可能的分配優化
如果有辦法事先確認協程狀態對象的生存周期一定比調用方的生存周期短,并且該協程的幀大小在調用的時候可以明確得到。 該優化即使對用戶自定義的內存分配器也可以使用。 這種情況下,被調用的協程的棧幀其實是內嵌在了調用方的函數棧幀中,就像一個迷你的內聯函數調用一樣。
Promise類型
實際的promise類型則由編譯器根據實際協程聲明中的簽名類型結合std::corountine_traits模板推到得出。
比如當一個協程的類型被定義為 task<float> foo(std::string x, bool flag),那么編譯器推導出來的類型為std::coroutine_traits<task<float>, std::string, bool>::promise_type。
如果協程被定義為非static的成員函數,比如task<void> my_class::method1(int x) const,對應的推導出來的Promise類型為std::coroutine_traits<task<void>, const my_class&, int>::promise_type,同時對象類型會被放置在第一個參數模板了行中。
編譯器支持情況
Visual Studio是這個提案的主推者之一,所以早在2013年MSVC就提供了自己的協程實現;并且在VS2017中正式將關鍵字向標準提案靠攏。 Clang也提供了支持,盡管其C++ Status頁面顯示的還是partial支持。 遺憾的是GCC的corountine支持還處于比較早期的階段,目前仍然在一個分支上開發。
原文:https://skyscribe.github.io/post/2019/06/23/cpp-20-modules-concepts-coroutine/
《新程序員》:云原生和全面數字化實踐50位技術專家共同創作,文字、視頻、音頻交互閱讀總結
以上是生活随笔為你收集整理的C++20 - 下一个大版本功能确定的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: JDK/JAVA 13正式版发布,此版本
- 下一篇: Python语言编程之LEGB变量作用域