在C語言中如何高效地複製和連接字符串?

2020-12-06 CSDN

就目前而言,在編程領域中,C語言的運用非常之多,它兼顧了高級語言的彙編語言的優點,相較於其它程式語言具有較大優勢。

作者 | Martin Sebor

譯者 | 蘇本如,責編 | 劉靜

以下為譯文:

在所有標準C語言<string.h>頭文件中聲明的字符串處理函數中,最常用的是那些用來複製和連接字符串的函數。這兩組函數都將字符從一個對象複製到另一個對象,並且都返回它們的第一個參數:指向目標對象的起始指針。這種返回值的方式是導致函數效率低下的一個原因,而這正是本文要探討的主題。

本文中展示的示例代碼僅僅用於說明目的。它們可能包含細微的錯誤,不應該被視為最佳代碼實踐。

標準解決方案

這種返回函數的第一個參數的設計,有時候會被不明白其用途的用戶所質疑。這樣的例子在StackOverflow網站上有不少,例如關於strcpy()返回值,或者C語言的strcpy為什麼返回它的參數?的討論。簡單的答案是,這是一個歷史性的意外。函數的第一個子集是由Unix第七版在1979年引入的,它由strcat、strncat、strcpy和strncpy函數組成。儘管這四個函數都在Unix的各種版本中使用,但通常情況下,對這些函數的調用卻沒有使用它們的返回值。儘管這些函數可以同樣很容易地定義為返回一個指針來指向最後一個複製的字符(或它的後一位),而且事實證明這種做法也非常有用。

兩個或多個字符串的連接操作的最佳複雜度和字符數量成線性關係。但是,如上所述,讓函數返回指向目標字符串的指針會導致操作的效率明顯低於最佳效率。該函數遍歷源字符串序列和目標字符串序列,並獲取指向這兩個序列末尾的指針。該指針指向函數(strncpy除外)附加到目標序列上的字符串結束符NUL('\0')處或它的後一位。但是,如果返回的指針指向第一個字符而不是最後一個字符(或它的下一個字符),NUL結束符的位置會丟失,必須在需要時重新計算。這種做法的低效率可以在將兩個字符串s1和s2連接到目標緩衝區d中的示例中得到說明。將一個字符串添加到另一個字符串的慣用方法(雖然遠非理想)是調用strcpy和strcat函數,如下所示:

strcat (strcpy (d, s1), s2);

為了執行這個連接操作,除了同時發生的相應地在d上的傳遞之外,一次在s1的傳遞和一次在s2上的傳遞是必須要執行的操作,但是上面的調用在s1上進行了兩次傳遞。讓我們把這些調用分成兩個語句。

char *d1 = strcpy (d, s1); // pass 1 over s1strcat (d1, s2); // pass 2 over the copy of s1 in d

因為strcpy返回其第一個參數d的值,所以d1的值與d相同。為簡單起見,在後面的示例中我們將使用d,而不是將返回值存儲在d1中並使用它。在strcat調用中,我們遍歷剛剛複製到d1的字符串以確定最後一個字符的位置,這個成本和第一個字符串s1的長度是線性關係。這個成本乘以每個要連接的字符串。因而最終整個連接操作的成本相當於連接數和所以字符串長度的乘積,趨於一種二次方的關係。這種低效率是如此的臭名昭著,以至於為自己贏得了一個名字:畫師施萊米爾算法。(另見http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2349.htm#sad-string)

必須指出的是,除了效率低下之外,strcat和strcpy還因其緩衝區溢出的問題而臭名昭著,因為它們都對複製字符的數量不做任何限制。

克服局限性的嘗試

當源字符串的長度未知且目標字符串大小固定時,遵循一些流行的安全編碼準則來將連接結果限制為目標區大小實際上會導致兩個冗餘的傳遞。例如,按照CERT關於安全使用strncpy()和strncat() 的建議,並且目標區的大小是dsize字節,我們可能會得到以下代碼。

strncpy (d, s1, dsize - 1); // pass 1 over s1 plus over d up to dsize - 1 d[dsize - 1] = '\0'; // remember to nul-terminatesize_t n = strlen (d); // pass 2 over copy of s1 in dstrncat (d, s2, dsize - n - 1); // pass 3 over copy of s1 in d

注意,與對strncat的調用不同,當s1的長度大於d的大小時,上面對strncpy的調用不會將NUL('\0')結束符追加到d上。它是一個常見的想當然的錯誤。此外,當s1短於dsize-1時,strncpy函數將所有剩餘的字符填滿為NUL('\0'),這也被視為一種浪費的,因為隨後對strncat的調用將覆蓋掉它們。

為了避免一些冗餘,程式設計師有時會選擇先計算字符串長度,然後使用memcpy,如下所示。這種方法仍然效率不高,而且更容易出錯,並且代碼難以閱讀和維護。

size_t s1len = strlen (s1); // pass 1 over s1if (dsize <= s1len) s1len = dsize - 1; // no need to nul-terminatememcpy (d, s1, s1len); // pass 2 over s1size_t s2len = strlen (s2); // pass 1 over s2if (dsize - s1len <= s2len) s2len = dsize - s1len - 1;memcpy (d + s1len, s2, s2len); // pass 2, over s2 d[s1len + s1len] = '\0'; // nul-terminate result

使用sprintf和snprintf進行連接

出於對代碼複雜性和可讀性的擔心,程式設計師們有時會使用snprintf函數進行字符串連接。

snprintf (d, dsize, "%s%s", s1, s2);

這樣做代碼的可讀性非常好,但是,由於snprintf的開銷相當大,它的低效率導致它可能比使用字符串函數慢幾個數量級。snprintf的開銷不僅是由於解析格式字符串,而且還由于格式化I/O函數實現中通常固有的複雜性。

一些編譯器(如GCC和Clang)試圖通過將非常簡單的sprintf和snprintf調用轉換為strcpy或memcpy調用以提高效率,避免了對I/O函數的某些調用的開銷(請參閱這個在線示例https://godbolt.org/z/RaWkyd)。然而,由於C庫中沒有等價的字符串函數,而只有當snprintf調用被證明不會導致輸出的截斷時,轉換才會完成,因此對snprintf的相應轉換很少能夠發生。memcpy本身不合適,因為它複製的字節數與指定的字節數完全相同,strncpy也不適合,因為它把目標字符串的最後的NUL結束符之後的位數都覆蓋了。

由於字符串的冗餘傳遞次數,將snprintf調用轉換為strlen和memcpy調用序列產生的額外開銷,也被視為得不償失。在這個頁面上,標題為Better builtin string functions部分列出了GCC優化器在這方面的一些限制,以及改進它的一些折中措施。

POSIX的stpcpy和stpncpy函數

為了幫助解決這個問題,在過去很多年裡出現了很多超出標準C的庫解決方案。POSIX標準包括stpcpy和stpncpy函數,這兩個函數的實現方法是如果找到NUL結束符,則返回指向該字符的指針。這些函數可以用來緩解上面提到的麻煩和低效率。

constchar* stpcpy(char* restrict, constchar* restrict);constchar* stpncpy(char* restrict, constchar* restrict, size_t);

特別是,在不考慮緩衝區溢出的情況下,可以像下面這樣調用stpcpy來連接字符串:

stpcpy (stpcpy (d, s1), s2);

然而,當字符串副本必須以目標大小為邊界時,等效地使用stpncpy並不會消除將第一個NUL字符之後的剩餘目標位置清零並直到邊界指定的最大字符位置的開銷。

char *ret = stpncpy (d, dsize, s1); // zeroes out d beyond the end of s1 dsize -= (ret - d); stpncpy (d, dsize, s2); // again zeroes out d beyond the end

所以,這個函數仍然效率低下,因為對它的每次調用都會將目標中剩餘的空間以及複製的字符串的末尾的空間清零。因此,這個操作的複雜性仍然是二次方的。效率低下的嚴重程度隨著目標的大小成比例地增加,而與被連接的字符串的長度成反比增加。

OpenBSD的strlcpy和strlcat函數

為了應對針對strcpy和strcat函數的弱點以及上面討論的strncpy和strncat的一些缺點的緩衝區溢出攻擊,OpenBSD項目在20世紀90年代末引入了一對替代API(strlcpy和strlcat),旨在使字符串複製和連接更加安全(http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2349.htm)。

size_t strlcpy (char* restrict, constchar* restrict, size_t);size_t strlcat (char* restrict, constchar* restrict, size_t);

strncpy和strlcpy函數之間的主要區別在於返回值:前者返回指向目標的指針,後者則返回複製的字符數。另一個區別是strlcpy函數總是在目標中只存儲一個NUL結束符。要連接s1和s2,可以按以下方式使用strlcpy函數:

size_t n = strlcpy (d, s1, dsize);dsize -= n;d += n;strlcpy (d, s2, dsize);

這使得strlcpy在使用性和簡單性方面都可以與snprintf相提並論(當然snprintf的開銷雖然恆定,但要大得多)。

除了OpenBSD以外,strlcpy和strlcat函數在其他系統上也可用,包括Solaris和Linux(在BSD兼容庫中)。但是由於這些系統不是由POSIX指定的,所以這兩個函數在那些系統中並不總是存在。

POSIX的memccpy函數

POSIX還定義了另一個函數memccpy,該函數具有上面討論過的所有理想屬性,可以用來解決上面的問題。

void* memccpy(void* restrict dst, constvoid* restrict src, int c, size_t n);

這個函數結合了memcpy、memchr的特性以及上面討論的API的最佳方面的特性。

和memchr一樣,它會掃描源序列以查找由其參數之一指定的字符的第一次出現。字符可以是任何值,包括零。和strlcpy一樣,它最多將指定數量的字符從源序列複製到目標序列,而不會寫入超出其範圍。這解決了有關strncpy和stpncpy的低效率的報怨。和stpcpy和stpncpy類似(儘管不完全相同),它返回一個指針,該指針指向指定字符的副本(如果存在)的後一位。(回想一下stpcpy和stpncpy返回一個指向複製的NUL的指針。)這避免了strcpy和strncpy固有的低效性。因此,可以使用memccpy重寫上面的第一個示例(strcat(strcpy(d,s1,s2))以避免在字符串上進行任何冗餘傳遞,如下所示。請注意,這裡使用SIZE_MAX作為大小限制,這個重寫無法避免原始示例中存在的目標緩衝區溢出的風險,因此應避免。

memccpy (memccpy (d, s1, '\0', SIZE_MAX) - 1, s2, '\0', SIZE_MAX);

為了避免緩衝區溢出的風險,需要為每個調用確定適當的大小限制並作為參數提供。因此,像在snprintf(d, dsize, "%s%s", s1, s2)函數中那樣限制目標大小的連接調用,可以像下面這樣計算目標大小:

char *p = memccpy (d, s1, '\0', dsize);dsize -= (p - d - 1);memccpy (p - 1, s2, '\0', dsize);

選擇一個解決方案

如果字符串函數返回指向最後一個存儲字符或它的後面一位的指針,而不是返回其第一個參數的值,則上面討論的效率問題可以得到解決。然而,在現有函數使用了接近半個世紀後,對其進行更改是不太可行的。

儘管解決現有C標準字符串函數的問題是不可行的,但是可以通過添加一個或多個不受相同限制的函數來在新代碼中緩解這個問題。由於C標準的章程正在對現有的實踐進行編纂整理,所以C語言標準化委員有義不容辭的責任調查這種功能是否已經存在於流行的實現中,如果已經存在,則應該考慮採納它們。如上文提到的這幾種解決方案。

在上面提到的解決方案中,memccpy函數是最通用和最高效的,它由ISO 標準支持。即使在POSIX標準實現之外,它的應用範圍最廣,爭議最小。

相比之下,stpcpy和stpncpy函數的通用性較差,stpncpy函數會產生不必要的開銷,因此無法達到既定的目標。這些函數在C2X中仍然值得採用,以提高移植性。詳情請參閱N2352–將stpcpy和stpncpy添加到C2X中的提案。

OpenBSD的strlcpy和strlcat函數雖然是最優的,但是它們的通用性較差,支持範圍也較低,而且沒有得到ISO標準的指定。

memccpy函數不僅存在於Unix實現的子集中,它還由另一個名為ISO/IEC 9945的ISO標準指定。ISO/IEC 9945還有另外一個名字,也即大家熟知的IEEE Std 1003.1, 2017版,或者簡言之- POSIX: memccpy,在那裡它是作為XSI擴展提供給C的。這個函數可以追溯到System V接口定義第1版(SVID1),最初於1985年發布。

memccpy甚至可以用於UNIX和POSIX以外的實現,例如:

安卓系統中的memccpy函數,蘋果Mac OS X中的memccpy函數,BlackBerry Native SDK 的memccpy函數,Compaq Run-Time Library for VAX中的memccpy函數,微軟Visual Studio C Runtime Library中的 memccpy 函數,IBM z/OS 中的memccpy函數.下面提供了一個簡單(但是效率低下)的memccpy參考實現:

void* memccpy(void* restrict dst, constvoid* restrict src, int c, size_t n){void *pc = memchr (src, c, n);void *ret;if (pc) { n = (char*)pc - (char*)src + 1; ret = (char*)dst + n; }else ret = 0;memcpy (dst, src, n);return ret; }

這個函數的一個更優化的實現可能如下。

void* memccpy(void* restrict dst, constvoid* restrict src, int c, size_t n){constchar *s = src;for (char *ret = dst; n; ++ret, ++s, --n) { *ret = *s;if ((unsignedchar)*ret == (unsignedchar)c)return ret + 1; }return0; }

藉助於memccpy的性能優化,編譯器將能夠把對snprintf (d, dsize, "%s", s)函數的簡單調用轉換為對memccpy(d, s, '\0', dsize)的最佳有效調用。通過以代碼大小換取速度,激進的優化器甚至可以將符合下列條件的snprintf函數調用(其格式字符串由多個%s指令組成,這些指令中間穿插有普通字符,如%s/%s)轉換成一系列的此類memccpy函數調用:如下所示

char *p = memccpy (d, s1, '\0', dsize);if (p) {--p; p = memccpy (p, "/", '\0', dsize - (p - d));if (p) {--p; p = memccpy (p, s2, '\0', dsize - (p - d)); } }if (!p) d[dsize - 1] = '\0';

2019年4月WG14會議後的更新

將memccpy函數和本文討論的其他標準函數(除了strlcpy和strlcat),以及另外兩個標準函數納入下一個C程式語言修訂版的提議於2019年4月提交給了C語言標準化委員會(見 3, 4, 5和 6)。委員會最終決定採納memccpy函數,但否決了其餘提案。

原文:https://developers.redhat.com/blog/2019/08/12/efficient-string-copying-and-concatenation-in-c/

本文為 CSDN 翻譯,轉載請註明來源出處。

相關焦點

  • 第四篇:C語言中指針與字符串核心知識點梳理
    前面在講變量的時候,其中有一個特點就是變量的內存地址,即:變量在內存中實際的保存位置。這個內存地址如何獲取?它又有什麼意義?C語言的基本數據類型中有一個char的關鍵詞,可以存儲單個的字符。那麼,像漢字以及由多個字符組成的內容,又該如何存儲呢?這點將涉及到本文第二個核心知識點:字符串及其常規操作。重點包括:字符串處理函數、指針與字符串的關係等。
  • C語言編程技巧:跟我學如何定義及使用一個字符串數組
    實現目的我們在用C語言編寫程序時,經常會遇到使用字符串數組的情況,這種數組的特點是, 數組中的每個元素都是一個字符串,但每個字符串的長度卻不相同。如果你使用C++語言進行編程的話,實現起來相對比較簡單,只需直接選擇標準模板庫的字符串string類,在代碼中定義該類的一個數組即可實現。現在的問題是,在純C語言中如何定義這樣的一個字符串數組呢?如對於下面的一個字符串數組:str = {「I love C.」,「I love C++.」,「I love JAVA.」
  • Go語言學習筆記之字符串一
    Go語言是一個年輕人,身上擁有c++,java,python等語言的特點。在網絡通信、並發和並行編程擁有極好的體驗,當然不僅僅在這上上面,還有網絡編程,web應用,應用下載等有著非常大的潛力。這裡列舉一些 Go 語言的特點: 簡化問題,易於學習 內存管理,簡潔語法,易於使用 快速編譯,高效開發 高效執行 並發支持,輕鬆駕馭, 靜態類型 標準類庫,規範統一 易於部署 文檔全面 免費開源學習go語言有幾天了,今天突然想到把學的寫成筆記,記錄一下。如有不正確的請指教。
  • 10個很棒的 JavaScript 字符串技巧
    我們稱一個字符序列為字符串。這幾乎是所有程式語言中都有的基本類型之一。這裡跟大家展示關於 JS 字符串的10個很棒的技巧,你可能還不知道哦?1.如何多次複製一個字符串JS 字符串允許簡單的重複,與純手工複製字符串不同,我們可以使用字符串的repeat方法。2. 如何填充一個字符串到指定的長度有時,我們希望字符串具有特定長度。
  • Python中去除字符串首尾空格、特殊字符和指定子字符串的方法
    第七十七節:去除字符串中的空格和特殊字符字符串在實際應用中,有很多情況是默認去除字符串首尾的空格狀態,去除幾個比較特殊的字符的。這幾個特殊的字符是:換行符「\n」、回車符「\r」、制表符「\t」。>去除字符串首尾指定的子字符串從strip()方法中,又延伸出了去除字符串開頭和結尾位置空格、特殊字符和指定子字符串的方法。
  • Excel小技巧|三種方法計算算式字符串
    Excel中針對一列算式字符串的問題,如果才能計算得出正確結果?如下圖所示,A列是一列算式字符串,如何計算其正確的結果,即如何在算式字符串前面加個"="並使之正常計算,這裡我們用三種方法處理,總有一種適合你哦!
  • Python基礎教程(一) - 序列:字符串、列表和元組
    對於字符串來說就是判斷一個字符是否屬於一個字符串;對於列表和元組,就代表一個對象是否屬於該對象。返回值一般來講是True/False,語法為:對象 [not] in 序列連結操作符(+):這個操作符允許我們把一個序列和另一個相同類型的序列做連接。
  • 在JavaScript字符串的search()方法中,如何匹配正則表達式?
    第一節:基本概念#JavaScript#正則表達式已經成為各大程式語言的標準,只是在不同的語言中,所使用的方式有所不同,但基本上核心的功能都是一樣的。正則表達式的核心功能是建立一種匹配模式,這個匹配模式可以理解為模板,模子。然後再拿具體的字符串來與這個模式進行匹配,如果匹配上,則表示符合要求,則進一步採用措施。
  • 漫畫:什麼是字符串匹配算法?
    讓我們來舉一個例子:在上圖中,字符串B是A的子串,B第一次在A中出現的位置下標是2(字符串的首位下標是0),所以返回 2。我們再看另一個例子:在上圖中,字符串B在A中並不存在,所以返回 -1。為了統一概念,在後文中,我們把字符串A稱為主串,把字符串B稱為模式串。
  • pandas向量化字符串操作方法!
    向量化的操作使我們不必擔心數組的長度和維度,只需要關係操作功能,尤為強大的是,除了支持常用的字符串操作方法,還集成了正則表達式的大部分功能,這使得pandas在處理字符串列時,具有非常大的魔力。例如,要計算每個單詞中『a』的個數,下面一行代碼就可以搞定,非常高效。假如用內置的字符串函數進行操作,需要進行遍歷,且Python原生的遍歷操作無法處理缺失值。
  • MySQL字符串截取 和 截取字符進行查詢
    通過mysql自帶的一些字符串截取函數,對數據進行處理,下面是我整理的字符串截取 和 截取字符進行查詢。一、MySQL中字符串的截取MySQL中有專門的字符串截取函數:其中常用的有兩種:substring_index(str,delim,count) 和concat 1.substring_index(str,delim,count) 函數的使用較為普遍。
  • R語言-stringr-字符串處理
    R包stringr處理字符相對簡單,尤其是我常用Power BI,但是對M語言不熟悉,不會處理字符數據,往往我就先利用R清洗字符數據列。本文記錄工作中常用的字符處理函數,部分案例照搬R for Data Science的字符部分。
  • 用Python拼接字符串的常用方法及性能分析
    如何拼接字符串本篇開始之前我們先看一下Python之禪中對於編碼的一些建議:Python之禪(import this試試看)中有一句說得很好:「Simple is better than complex」這句話解釋為
  • LABVIEW編程之時間標識轉換為字符串
    在定時函數選板中,LABVIEW提供了許多的時間類相關函數,包括時間、日期轉換為字符串函數,實際上格式化寫入字符串函數完全支持時間標識,其轉換功能更多、更全面。與數值轉換為字符串類似,時間標識轉換為字符串的關鍵也是格式化字符串,LABVIEW提供了許多專門的時間相關的時間格式代碼,這些格式符不僅僅可以用來轉化為字符串,同時也可以時間標識控制項進行特色顯示,以下的例程中將同時利用字符串和時間標識顯示控制項顯示我們需要的時間日期。
  • Python中字符串編碼在二進位之間相互轉換的方法
    第八十節:字符串編碼轉換在學習「計算字符串的長度」(詳見第72節內容Python中如何計算字符串的長度),對編碼的概念、分類和作用,做過一個簡單的介紹,今天的內容,還是從「編碼」開始談。我們知道,機器語言本質上就是0和1組成的二進位語言,所以str和bytes字符在不能拼接的情況下,它們之間的轉換就非常必要了,因為在儲存和傳輸的時候,是必須要將str字符類型轉換為bytes字節類型的。今天就來學習如何str和bytes類型之間轉換的方法。
  • C語言字符集由字母,數字,空格,標點和特殊字符組成
    C源程序的結構特點1.一個C語言源程序可以由一個或多個源文件組成。2.每個源文件可由一個或多個函數組成。4.源程序中可以有預處理命令(include 命令僅為其中的一種),預處理命令通常應放在源文件或源程序的最前面。5.每一個說明,每一個語句都必須以分號結尾。但預處理命令,函數頭和花括號「}」之後不能加分號。6.標誌符,關鍵字之間必須至少加一個空格以示間隔。
  • C語言中的char類型也有signed和unsigned?字符也有正負之分嗎?
    字符怎麼可能還區分正字符和負字符呢?字符怎麼可能還區分正字符和負字符呢?其實,C語言中並沒有專門用來表示字符的數據類型,事實上,char 像 int、short 類型一樣,也是一種整型,只不過,char 類型是最短的整型而已,所以它當然可以有 signed 和 unsigned 兩種類型。
  • 從字符級的語言建模開始,了解語言模型與序列建模的基本概念
    你有沒有想過 Gmail 自動回復是如何進行的?或者手機在你輸入文本時如何對下一個詞提出建議?生成文本序列的通常方式是訓練模型在給定所有先前詞/字符的條件下預測下一個詞/字符出現的概率。此類模型叫作統計語言模型,這種模型會嘗試捕捉訓練文本的統計結構,本文從字符級語言模型和名字預測出發向讀者介紹了語言建模的核心概念。
  • 幾個常用的Excel字符串函數,職場人精英必備,直接複製使用
    Excel是我們日常工作中的必備工具軟體之一,想要利用Excel提高工作效率,就離不開函數公式,Excel函數公式有很多,想一下全部掌握是不可能的,今天小編和大家分享幾個工作中常的函數公式。不想加班的,那就趕緊加入Excel與財務的學習大軍吧!
  • 單片機的C語言中數組的用法
    數組是由數組名來表示的,數組中的數據由特定的下標來唯一確定。引入數組的目的,是使用一塊連續的內存空間存儲多個類型相同的數據,以解決一批相關數據的存儲問題。數組與普通變量一樣,也必須先定義,後使用。數組在C51語言的地位舉足輕重,因此深入地了解數組是很有必要的。下面就對數組進行詳細的介紹。