如何以标准化的方式去除字符串开头/结尾的空格?

206

在C语言中,是否有一种干净、最好是标准的方法可以从字符串中裁剪前导和尾随的空格?我可以自己编写代码,但我认为这是一个常见的问题,应该有同样常见的解决方案。

40个回答

203

如果您可以修改字符串:

// Note: This function returns a pointer to a substring of the original string.
// If the given string was allocated dynamically, the caller must not overwrite
// that pointer with the returned value, since the original pointer must be
// deallocated using the same allocator with which it was allocated.  The return
// value must NOT be deallocated using free() etc.
char *trimwhitespace(char *str)
{
  char *end;

  // Trim leading space
  while(isspace((unsigned char)*str)) str++;

  if(*str == 0)  // All spaces?
    return str;

  // Trim trailing space
  end = str + strlen(str) - 1;
  while(end > str && isspace((unsigned char)*end)) end--;

  // Write new null terminator character
  end[1] = '\0';

  return str;
}

如果您无法修改字符串,则可以使用基本相同的方法:

// Stores the trimmed input string into the given output buffer, which must be
// large enough to store the result.  If it is too small, the output is
// truncated.
size_t trimwhitespace(char *out, size_t len, const char *str)
{
  if(len == 0)
    return 0;

  const char *end;
  size_t out_size;

  // Trim leading space
  while(isspace((unsigned char)*str)) str++;

  if(*str == 0)  // All spaces?
  {
    *out = 0;
    return 1;
  }

  // Trim trailing space
  end = str + strlen(str) - 1;
  while(end > str && isspace((unsigned char)*end)) end--;
  end++;

  // Set output size to minimum of trimmed string length and buffer size minus 1
  out_size = (end - str) < len-1 ? (end - str) : len-1;

  // Copy trimmed string and add null terminator
  memcpy(out, str, out_size);
  out[out_size] = 0;

  return out_size;
}

8
抱歉,第一个答案完全不好,除非您不关心内存泄漏。现在您有两个重叠的字符串(原始字符串已削减其尾部空格,新字符串)。只有原始字符串可以释放,但如果这样做,第二个字符串将指向已释放的内存。 - David Nehme
9
没有分配内存,因此也不存在需要释放的内存。 - Adam Rosenfield
18
@nvl:不行。str是一个局部变量,改变它并不会改变传入的原始指针。在C语言中,函数调用始终是按值传递,而不是按引用传递。 - Adam Rosenfield
14
@Raj:返回一个不同于传入地址的地址并没有本质上的问题。这里没有要求返回的值必须是 free() 函数的有效参数。相反,我设计它是为了避免出于效率考虑而需要进行内存分配。如果传入的地址是动态分配的,则调用者仍然负责释放该内存,并且调用者需要确保不要用此处返回的值覆盖那个值。 - Adam Rosenfield
4
你需要将isspace函数的参数转换为unsigned char类型,否则会导致未定义行为。 - Roland Illig
显示剩余13条评论

45

这里有一个将字符串移动到缓冲区第一位置的方法。您可能希望采用这种行为,这样,如果您动态分配了该字符串,仍然可以在与trim()返回的指针相同的指针上释放它:

char *trim(char *str)
{
    size_t len = 0;
    char *frontp = str;
    char *endp = NULL;

    if( str == NULL ) { return NULL; }
    if( str[0] == '\0' ) { return str; }

    len = strlen(str);
    endp = str + len;

    /* Move the front and back pointers to address the first non-whitespace
     * characters from each end.
     */
    while( isspace((unsigned char) *frontp) ) { ++frontp; }
    if( endp != frontp )
    {
        while( isspace((unsigned char) *(--endp)) && endp != frontp ) {}
    }

    if( frontp != str && endp == frontp )
            *str = '\0';
    else if( str + len - 1 != endp )
            *(endp + 1) = '\0';

    /* Shift the string so that it starts at str so that if it's dynamically
     * allocated, we can still free it on the returned pointer.  Note the reuse
     * of endp to mean the front of the string buffer now.
     */
    endp = str;
    if( frontp != str )
    {
            while( *frontp ) { *endp++ = *frontp++; }
            *endp = '\0';
    }

    return str;
}

测试正确性:

#include <stdio.h>
#include <string.h>
#include <ctype.h>

/* Paste function from above here. */

int main()
{
    /* The test prints the following:
    [nothing to trim] -> [nothing to trim]
    [    trim the front] -> [trim the front]
    [trim the back     ] -> [trim the back]
    [    trim front and back     ] -> [trim front and back]
    [ trim one char front and back ] -> [trim one char front and back]
    [ trim one char front] -> [trim one char front]
    [trim one char back ] -> [trim one char back]
    [                   ] -> []
    [ ] -> []
    [a] -> [a]
    [] -> []
    */

    char *sample_strings[] =
    {
            "nothing to trim",
            "    trim the front",
            "trim the back     ",
            "    trim front and back     ",
            " trim one char front and back ",
            " trim one char front",
            "trim one char back ",
            "                   ",
            " ",
            "a",
            "",
            NULL
    };
    char test_buffer[64];
    char comparison_buffer[64];
    size_t index, compare_pos;

    for( index = 0; sample_strings[index] != NULL; ++index )
    {
        // Fill buffer with known value to verify we do not write past the end of the string.
        memset( test_buffer, 0xCC, sizeof(test_buffer) );
        strcpy( test_buffer, sample_strings[index] );
        memcpy( comparison_buffer, test_buffer, sizeof(comparison_buffer));

        printf("[%s] -> [%s]\n", sample_strings[index],
                                 trim(test_buffer));

        for( compare_pos = strlen(comparison_buffer);
             compare_pos < sizeof(comparison_buffer);
             ++compare_pos )
        {
            if( test_buffer[compare_pos] != comparison_buffer[compare_pos] )
            {
                printf("Unexpected change to buffer @ index %u: %02x (expected %02x)\n",
                    compare_pos, (unsigned char) test_buffer[compare_pos], (unsigned char) comparison_buffer[compare_pos]);
            }
        }
    }

    return 0;
}

源文件名为trim.c。使用'cc -Wall trim.c -o trim'进行编译。


2
你必须将 isspace 的参数转换为 unsigned char,否则会引发未定义的行为。 - Roland Illig
@RolandIllig:谢谢,我从未意识到这是必要的。已经修复了。 - indiv
@Simas:你为什么这么说?函数调用了isspace(),那么" ""\n"之间有什么区别呢?我已经添加了针对换行符的单元测试,看起来没问题...https://ideone.com/bbVmqo - indiv
1
@indiv 当手动分配内存时,它将访问无效的内存块。也就是说,这行代码:*(endp + 1) = '\0';。答案中的示例测试使用了一个大小为64的缓冲区,从而避免了这个问题。 - Simas
@Simas是正确的。我最喜欢这个答案,但在特殊情况下,如果字符串完全由空格组成,则该行存在错误。可以通过以下方式进行验证: char* ptr = strdup(" "); printf("trim is [%s]\n", trim(ptr)); free(ptr); return 0; }``` `gcc -g test.c; valgrind a.out` 这是因为当frontp移动到字符串的末尾时,endp从未向后移动超出缓冲区的最终空终止符,因此写入*(endp+1)超出了缓冲区。修复方法是交换围绕第22行的“if”和“else if”操作的顺序。 - nolandda
1
@nolandda:非常感谢您提供的详细信息。我已经修复了它,并更新了测试以检测缓冲区溢出,因为我目前无法访问valgrind。 - indiv

26

我的解决方案。字符串必须是可变的。相较于其他一些解决方案,它的优点在于将非空格部分移动到开头,因此您可以继续使用旧指针,以防以后需要释放它。

void trim(char * s) {
    char * p = s;
    int l = strlen(p);

    while(isspace(p[l - 1])) p[--l] = 0;
    while(* p && isspace(* p)) ++p, --l;

    memmove(s, p, l + 1);
}   

此版本使用strndup()创建字符串副本,而不是直接进行原地编辑。strndup()需要_GNU_SOURCE,因此您可能需要使用malloc()和strncpy()创建自己的strndup()。

char * trim(char * s) {
    int l = strlen(s);

    while(isspace(s[l - 1])) --l;
    while(* s && isspace(* s)) ++s, --l;

    return strndup(s, l);
}

5
如果s为空字符串,则调用trim()会引起未定义行为,因为第一个isspace()调用将会是isspace(p[-1]),而p[-1]不一定指向一个合法的位置。 - chux - Reinstate Monica
1
你必须将 isspace 的参数转换为 unsigned char,否则会引发未定义的行为。 - Roland Illig
1
应该添加 if(l==0)return; 来避免零长度的字符串。 - ch271828n

11

这是我的C小型库,可对左侧、右侧、两侧、全部字符进行原地和分离的修剪,并修剪一组指定的字符(默认为空格)。

strlib.h文件的内容:

#ifndef STRLIB_H_
#define STRLIB_H_ 1
enum strtrim_mode_t {
    STRLIB_MODE_ALL       = 0, 
    STRLIB_MODE_RIGHT     = 0x01, 
    STRLIB_MODE_LEFT      = 0x02, 
    STRLIB_MODE_BOTH      = 0x03
};

char *strcpytrim(char *d, // destination
                 char *s, // source
                 int mode,
                 char *delim
                 );

char *strtriml(char *d, char *s);
char *strtrimr(char *d, char *s);
char *strtrim(char *d, char *s); 
char *strkill(char *d, char *s);

char *triml(char *s);
char *trimr(char *s);
char *trim(char *s);
char *kill(char *s);
#endif

strlib.c的内容:

#include <strlib.h>

char *strcpytrim(char *d, // destination
                 char *s, // source
                 int mode,
                 char *delim
                 ) {
    char *o = d; // save orig
    char *e = 0; // end space ptr.
    char dtab[256] = {0};
    if (!s || !d) return 0;

    if (!delim) delim = " \t\n\f";
    while (*delim) 
        dtab[*delim++] = 1;

    while ( (*d = *s++) != 0 ) { 
        if (!dtab[0xFF & (unsigned int)*d]) { // Not a match char
            e = 0;       // Reset end pointer
        } else {
            if (!e) e = d;  // Found first match.

            if ( mode == STRLIB_MODE_ALL || ((mode != STRLIB_MODE_RIGHT) && (d == o)) ) 
                continue;
        }
        d++;
    }
    if (mode != STRLIB_MODE_LEFT && e) { // for everything but trim_left, delete trailing matches.
        *e = 0;
    }
    return o;
}

// perhaps these could be inlined in strlib.h
char *strtriml(char *d, char *s) { return strcpytrim(d, s, STRLIB_MODE_LEFT, 0); }
char *strtrimr(char *d, char *s) { return strcpytrim(d, s, STRLIB_MODE_RIGHT, 0); }
char *strtrim(char *d, char *s) { return strcpytrim(d, s, STRLIB_MODE_BOTH, 0); }
char *strkill(char *d, char *s) { return strcpytrim(d, s, STRLIB_MODE_ALL, 0); }

char *triml(char *s) { return strcpytrim(s, s, STRLIB_MODE_LEFT, 0); }
char *trimr(char *s) { return strcpytrim(s, s, STRLIB_MODE_RIGHT, 0); }
char *trim(char *s) { return strcpytrim(s, s, STRLIB_MODE_BOTH, 0); }
char *kill(char *s) { return strcpytrim(s, s, STRLIB_MODE_ALL, 0); }

这个主要程序可以完成所有任务。如果src == dst,它会原地修剪,否则,它的工作方式类似于strcpy例程。它可以修剪在字符串delim中指定的一组字符,或者如果为空,则修剪空格。它可以修剪左侧、右侧、两侧和所有(如tr)。它没有太多的内容,只需要对字符串进行一次迭代。有些人可能会抱怨trim right从左侧开始,但是,无论如何都需要从左侧开始计算strlen。 (为了进行正确的修剪,您必须以某种方式到达字符串的末尾,因此最好在进行迭代时进行计算。)可能会有关于流水线和缓存大小等方面的争论 - 谁知道呢。由于该解决方案从左到右工作并且仅迭代一次,因此可以扩展为在流上工作。限制:它不能处理unicode字符串。


2
我点赞了这个帖子,虽然它有点老,但我认为存在一个 bug。在使用 dtab[*d] 作为数组下标之前,没有将 *d 强制转换为 unsigned int。在一个带有有符号字符的系统上,这将读取到 dtab[-127],这可能会导致错误和崩溃。 - Zan Lynx
3
dtab [* delim ++] 可能存在未定义行为,因为 char 索引值必须转换为 unsigned char。 该代码假定 char 是 8 位的。 delim 应声明为 const char *dtab [0xFF &(unsigned int)* d] 更清晰,因为它是 dtab [(unsigned char)* d]。 该代码适用于 UTF-8 编码的字符串,但不会剥离非 ASCII 空格序列。 - chqrlie
@michael-plainer,这看起来很有趣。为什么不测试一下并将其放在GitHub上? - Daisuke Aramaki

10

这是我尝试编写的一个简单但正确的原地修剪函数。

void trim(char *str)
{
    int i;
    int begin = 0;
    int end = strlen(str) - 1;

    while (isspace((unsigned char) str[begin]))
        begin++;

    while ((end >= begin) && isspace((unsigned char) str[end]))
        end--;

    // Shift all characters back to the start of the string array.
    for (i = begin; i <= end; i++)
        str[i - begin] = str[i];

    str[i - begin] = '\0'; // Null terminate string.
}

2
建议更改为 while ((end >= begin) && isspace(str[end])),以防止在 str 为空字符串时出现 UB。这可以避免 str[-1] 的情况。 - chux - Reinstate Monica
顺便提一下,为了让它正常工作,我必须将其更改为 str[i - begin + 1]。 - truongnm
1
你必须将 isspace 的参数强制转换为 unsigned char,否则会调用未定义的行为。 - Roland Illig
@RolandIllig,为什么会是未定义行为?该函数旨在处理字符。 - wovano
@wovano 不是这样的。<ctype.h> 中的函数旨在使用 int 类型,该类型表示无符号字符或特殊值 EOF。请参见 https://dev59.com/mVrUa4cB1Zd3GeqPkHUT。 - Roland Illig
@RolandIllig,我所说的“预期”是指isspace()的实际用途是测试字符是否为空格。 我想不到调用isspace()来传入任意的int值的原因。 所以我希望实现会处理char,如果必要,就在内部执行一个无符号的char转换。 但是你对标准的理解是正确的。 有时候我忘记了C标准是多么地保守和过时:( - wovano

9

晚来的修剪派对

特点:
1. 快速修剪开头,与其他答案中的方法相似。
2. 到达结尾后,每个循环仅使用 1 次测试以右侧修剪。类似于 @jfm3,但适用于所有空格字符串。
3. 为避免符号化字符是有符号字符时出现未定义行为,请将 *s 强制转换为 unsigned char

字符处理“在所有情况下,该参数都是一个 int 值,其值应表示为无符号字符或等于宏 EOF 的值。如果参数具有任何其他值,则行为是未定义的。” C11 §7.4 1

#include <ctype.h>

// Return a pointer to the trimmed string
char *string_trim_inplace(char *s) {
  while (isspace((unsigned char) *s)) s++;
  if (*s) {
    char *p = s;
    while (*p) p++;
    while (isspace((unsigned char) *(--p)));
    p[1] = '\0';
  }

  // If desired, shift the trimmed string

  return s;
}

@chqrlie评论说上面的代码没有移动截取后的字符串。要做到这一点,可以进行如下操作...

// Return a pointer to the (shifted) trimmed string
char *string_trim_inplace(char *s) {
  char *original = s;
  size_t len = 0;

  while (isspace((unsigned char) *s)) {
    s++;
  } 
  if (*s) {
    char *p = s;
    while (*p) p++;
    while (isspace((unsigned char) *(--p)));
    p[1] = '\0';
    // len = (size_t) (p - s);   // older errant code
    len = (size_t) (p - s + 1);  // Thanks to @theriver
  }

  return (s == original) ? s : memmove(original, s, len + 1);
}

3
太好了,终于有人知道 ctype 未定义行为了。 - Roland Illig

4
这是一个类似于@adam-rosenfields就地修改例程的解决方案,但不需要不必要地使用strlen()。与@jkramer一样,该字符串在缓冲区内向左对齐,因此您可以释放相同的指针。对于大型字符串来说并不是最优的,因为它不使用memmove。包括@jfm3提到的++/--运算符。FCTX基于的单元测试已包含在内。
#include <ctype.h>

void trim(char * const a)
{
    char *p = a, *q = a;
    while (isspace(*q))            ++q;
    while (*q)                     *p++ = *q++;
    *p = '\0';
    while (p > a && isspace(*--p)) *p = '\0';
}

/* See http://fctx.wildbearsoftware.com/ */
#include "fct.h"

FCT_BGN()
{
    FCT_QTEST_BGN(trim)
    {
        { char s[] = "";      trim(s); fct_chk_eq_str("",    s); } // Trivial
        { char s[] = "   ";   trim(s); fct_chk_eq_str("",    s); } // Trivial
        { char s[] = "\t";    trim(s); fct_chk_eq_str("",    s); } // Trivial
        { char s[] = "a";     trim(s); fct_chk_eq_str("a",   s); } // NOP
        { char s[] = "abc";   trim(s); fct_chk_eq_str("abc", s); } // NOP
        { char s[] = "  a";   trim(s); fct_chk_eq_str("a",   s); } // Leading
        { char s[] = "  a c"; trim(s); fct_chk_eq_str("a c", s); } // Leading
        { char s[] = "a  ";   trim(s); fct_chk_eq_str("a",   s); } // Trailing
        { char s[] = "a c  "; trim(s); fct_chk_eq_str("a c", s); } // Trailing
        { char s[] = " a ";   trim(s); fct_chk_eq_str("a",   s); } // Both
        { char s[] = " a c "; trim(s); fct_chk_eq_str("a c", s); } // Both

        // Villemoes pointed out an edge case that corrupted memory.  Thank you.
        // https://dev59.com/knVD5IYBdhLWcg3wAGiD#NZ0IoYgBc1ULPQZFJ5LK
        {
          char s[] = "a     ";       // Buffer with whitespace before s + 2
          trim(s + 2);               // Trim "    " containing only whitespace
          fct_chk_eq_str("", s + 2); // Ensure correct result from the trim
          fct_chk_eq_str("a ", s);   // Ensure preceding buffer not mutated
        }

        // doukremt suggested I investigate this test case but
        // did not indicate the specific behavior that was objectionable.
        // http://stackoverflow.com/posts/comments/33571430
        {
          char s[] = "         foobar";  // Shifted across whitespace
          trim(s);                       // Trim
          fct_chk_eq_str("foobar", s);   // Leading string is correct

          // Here is what the algorithm produces:
          char r[16] = { 'f', 'o', 'o', 'b', 'a', 'r', '\0', ' ',                     
                         ' ', 'f', 'o', 'o', 'b', 'a', 'r', '\0'};
          fct_chk_eq_int(0, memcmp(s, r, sizeof(s)));
        }
    }
    FCT_QTEST_END();
}
FCT_END();

这个解决方案非常危险!如果原始字符串不包含任何非空格字符,则 trim 的最后一行会快乐地覆盖 a 之前的任何内容,如果这些字节恰好包含“空格”字节。编译时不要进行优化,看看 y 会发生什么:unsigned x = 0x20202020; char s[4] = " "; unsigned y = 0x20202020;printf("&x,&s,&y = %p,%p,%p\n", &x, &s, &y); printf("x, [s], y = %08x, [%s], %08x\n", x, s, y); trim_whitespace(s); printf("x, [s], y = %08x, [%s], %08x\n", x, s, y); - Villemoes
@Villemoes,感谢您的错误报告。我已经更新了逻辑,以避免在字符串仅包含空格时走出缓冲区的左侧。这个新版本是否解决了您的问题? - Rhys Ulerich
语言专家可能会因为你仅仅想象一下创建指向'a'前面的char的指针(这就是你的'--p'将要做的事情)而大声抗议。在现实世界中,你可能没问题。但你也可以将'>='改为'>',并将p的递减移动到'isspace(* - ​​- p)'。 - Villemoes
我认为律师们会没问题,因为这只是比较地址而不涉及其内容,但我确实喜欢你关于减量的建议。我已经相应地更新了它。谢谢。 - Rhys Ulerich
算法有误。尝试去除“ foobar”并查看结果。 - michaelmeyer
1
doukremt,你的关注点是 foobar 后的整个缓冲区没有填充为零吗?如果是这样,明确地说出来会更有帮助,而不是抛出模糊的问题。 - Rhys Ulerich

4

另一个例子,只需要一行代码就能完成真正的工作:

#include <stdio.h>

int main()
{
   const char *target = "   haha   ";
   char buf[256];
   sscanf(target, "%s", buf); // Trimming on both sides occurs here
   printf("<%s>\n", buf);
}

1
使用scanf是个好主意;但这只适用于单个单词,这可能不是OP想要的(即修剪“a b c”应该得到“a b c”,而您的单个scanf只会得到“a”)。因此,我们需要一个循环,并使用%n转换说明符计算跳过的字符数,在最后手动处理可能更简单。 - Peter - Reinstate Monica
当您想要字符串的第一个单词而不考虑任何初始空格时,这非常有用。 - J...S

3
我不太喜欢这些答案,因为它们中的大部分都做了以下一项或多项操作:
  1. 在原始指针的字符串内返回不同的指针(需要同时操作两个指向同一事物的指针,有点麻烦)。
  2. 过度使用像strlen()这样预遍历整个字符串的函数。
  3. 使用非可移植的特定于操作系统的库函数。
  4. 向后扫描。
  5. 使用与' '进行比较而不是isspace(),以便保留TAB / CR / LF。
  6. 浪费大量静态缓冲区的内存。
  7. 浪费高成本函数(例如sscanf/sprintf)的时间。
下面是我的版本:
void fnStrTrimInPlace(char *szWrite) {

    const char *szWriteOrig = szWrite;
    char       *szLastSpace = szWrite, *szRead = szWrite;
    int        bNotSpace;

    // SHIFT STRING, STARTING AT FIRST NON-SPACE CHAR, LEFTMOST
    while( *szRead != '\0' ) {

        bNotSpace = !isspace((unsigned char)(*szRead));

        if( (szWrite != szWriteOrig) || bNotSpace ) {

            *szWrite = *szRead;
            szWrite++;

            // TRACK POINTER TO LAST NON-SPACE
            if( bNotSpace )
                szLastSpace = szWrite;
        }

        szRead++;
    }

    // TERMINATE AFTER LAST NON-SPACE (OR BEGINNING IF THERE WAS NO NON-SPACE)
    *szLastSpace = '\0';
}

2
你必须将 isspace 的参数转换为 unsigned char,否则会引发未定义的行为。 - Roland Illig
由于这个答案涉及到“浪费的周期”,请注意,当没有空格时,代码会不必要地复制整个字符串。一个前导的 while (isspace((unsigned char) *szWrite)) szWrite++; 可以防止这种情况发生。代码还会复制所有尾随的空格。 - chux - Reinstate Monica
@chux 这个实现使用单独的读写指针就地进行变异(而不是在不同位置返回新指针),因此将szWrite跳转到第一行的第一个非空格建议会在原始字符串中留下前导空格。 - Jason Stewart
@chux,你说得对,它确实会复制尾随的空格(在最后一个非空格字符后添加空值之前),但这是我选择为了避免预扫描字符串而付出的代价。对于适度数量的尾随WS,只需复制字节比预先扫描整个字符串以查找最后一个非WS字符更便宜。对于大量的尾随WS,预先扫描可能值得减少写入。 - Jason Stewart
@chux,对于“没有空间时的复制”情况,只有在指针不相等时执行*szWrite = *szRead才会跳过这种情况下的写入,但是我们又添加了另一个比较/分支。对于现代CPU/MMU/BP,我不知道这个检查是否会损失或获得性能。对于更简单的处理器和内存架构,直接进行复制并跳过比较更便宜。 - Jason Stewart

3
如果您正在使用glib,那么可以使用g_strstrip函数。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接