《C++性能优化指南》 linux版代码及原理解读 第四章

目录

概述

为什么字符串很麻烦

字符串是动态分配的

字符串赋值背后的操作

如何面对字符串会进行大量复制

写时复制COW(copy on write)

尝试优化字符串

避免临时字符串

通过预留存储空间减少内存分配

通过传递引用减少实参复制

使用迭代器操作

减少循环中的比较操作

减少返回值的复制

还没有结束,使用字符数组代替字符串

再次优化字符串

尝试其他的算法

叠加以前的优化方式

使用其他的编译器

使用其他字符串的库

功能丰富的字符串库

使用std::stringstream避免值语义

自己实现一种新的字符串

其他的字符串方式std::string_view

用更好的内存分配器

消除字符串的类型转换

将C字符串转换为std::string

不同字符集间的转换


概述

本章主要是通过字符串的优化来一步步引导读者深入理解什么是代码优化,以及有哪些启发式的方式。

        谷歌Chromium2开发者论坛上的一篇帖子中提到,“在Chromium中,std::string对内存管理器的调用次数占到了内存管理器被调用的总次数的一半”。

为什么字符串很麻烦

字符串是动态分配的

以下是两种风格的字符串,显而易见的是,动态分配的字符串对于编程来说无疑有很大的便利性。

//C风格的字符数组
char *str = (char*) malloc(10);
strcpy(p, "string");    
... 
free(p);

//字符串
std::string str ("string");

        在std::string 便利的背后,其实也有一些问题:比如如果我们像str的后面添加一些字符,这个时候原来的str的缓冲区是不是够用?如果不够用的话,那会怎么操作让原始的str("string")连接上新的字符呢?可能string会在申请地址的时候,申请大于所需(“string”)字符串大小(6字节)的空间,实际可能会申请12字节空间,这样我们在后面添加字符的时候不需要频繁的申请内存以及移动数据了,但是这也导致一个问题,那就是会有无用的空间被占用掉了。

字符串赋值背后的操作

        如果有一条语句 s1 = s2 + s3 + s4 ; 这里面发生了那些操作呢?

s1 = s2 + s3 + s4 ;
/**
*第一步:临时变量 s23 = s2 + s3;
*第二步:临时变量 s234 = s23 + s4 ;
*第三步:s1 = s234;
*第四步: 释放 s23 . s234.

        可以看到,这里面多次调用了内存管理器。

如何面对字符串会进行大量复制

        在我们的日常使用中,我们通常会使用string a = b ;这种方式进行字符串的复制,但是每次这样就会生成一个b字符串的副本,开销会变得很大。

        有几种方式会减少这样的问题,一是在将string作为参数传入的函数中,使用变值函数;二是通过引用的方式。尤其是在C++11 之后,加入了“右值引用”和“移动语义”,这个特性就能避免无谓的复制发生。

写时复制COW(copy on write)

        还有一种方式就是更改string的实现,通过使用写时复制”(copy on write)这种方式,假设我们把string重写,让它类似于智能指针一样,在 ”=“ 操作的时候,实际上我们只是将b的指针传过来,然后将b内部的引用计数+1 。当我们要执行任何需要改变值的操作时,我们会先判断引用计数,如果发现有多个字符串都指向了同一个内存空间,那就先将原本的内存数据复制一份,然后进行更改。

COWstring s1, s2; 
s1 = "hot";     // s1是"hot" 
s2 = s1;        // s2是"hot"(s1和s2指向相同的内存)
s1[0] = 'n';    // s1会在改变它的内容之前将当前内存空间中的内容复制一份            
                // s2仍然是"hot",但s1变为了"not"

        这种方法乍看起来确实很美好,既节省了空间,同时复制赋值操作也很快速,但是在实际的使用中,却会有很多的问题。如果是在一种频繁更改字符串内容的环境中,每次的字符串内容的更改首选需要多进行一步操作,那就是先检查它的引用,然后复制原始数据,更改数据为需要的内容。在访问引用计数这里,我想很多人会想到一个智能指针在多线程中会面对的问题,如何保证引用计数的副本是最新的呢?当这个问题被抛出来的时候,我想COW这种美好的景象就破坏的七七八八了。

尝试优化字符串

        下面的例子是输入一个字符串,将输入的字符串当中的控制字符移除。

        

std::string remove_ctrl(std::string s) {     
    std::string result;     
    for (int i=0; i<s.length(); ++i) {         
        if(s[i] >= 0x20)             
            result = result + s[i];     
    }     
    return result; 
}

        我想如果我写的话,第一种想到的写法就是这样。乍看之下没有问题,它能实现我们预期的效果,但是分析里面的代码,我们会发现result = result + s[i]; 这一句代码会使整段代码中开销最大的代码,保守来说,那就是生成临时每一次这个语句的执行都会生成一个临时对象,而这个临时对象会对应内存管理器进行内存分配,以及释放内存。至于result的 ”=" 操作,我们可以讨论一下:

  •         如果string是用COW的方式实现的,那么 ”=”操作会执行一次指针复制操作,同时增加引用计数。
  •         如果string是使用无共享缓冲区的方式实现的,那么赋值运算就需要将临时字符串的内容复制进来。
  •         如果string的实现是简单的,或者缓冲区没有足够的容量,那么每次操作都会分配一个新的缓冲区用于存放连接的结果。也就是说会触发内存分配操作。
  •         如果编译器实现了C++11的特性,那么 = 号右边的result + s [ i ]是一个右值,如果result实现了移动构造函数的话,那么就不需要复制。

OutPut:

test Remove_ctrl function : start
test Remove_ctrl function : stop
Total take 172  ticks and takes 0  mS

避免临时字符串

  通过复合操作减少临时字符串

std::string remove_ctrl_mutating(std::string s) {     
    std::string result;     
    for (int i=0; i<s.length(); ++i) {         
        if(s[i] >= 0x20) 
            result += s[i];  
    }   
    return result; 
}

OutPut:

test Remove_ctrl_mutating function : start
test Remove_ctrl_mutating function : stop
Total take 21  ticks and takes 0  mS

        通过避免使用临时字符串,时间从21 ticks 减少到了2 ticks 。

通过预留存储空间减少内存分配

        在之前我们讨论的string的实现方法中,如果缓冲区的容量已经满了,或者string内部的存储空间占用到了一定的比例,再向其中加入字符时都会触发申请新内存-移动数据并合并数据的操作,这样就可以减少一部分的内存分配操作。

        

std::string remove_ctrl_reserve(std::string s) {     
    std::string result; 
    result.reserve(s.length());    
    for (int i=0; i<s.length(); ++i) {         
        if (s[i] >= 0x20)             
            result += s[i];     
    }     
    return result; 
}

        按照逻辑分析,以及按照书中的说法,这种方式会比上一种更快17%左右,但是我这里的实际测试出来这个跟上一种方式的差别不大,甚至会比上一种更慢一点。但是起码来说,这种方法也是比原始版本的代码快了十几倍。后续如果有时间我还会对代码进行分析原因。

通过传递引用减少实参复制

        在传入参数的时候,还是会发生字符串的复制,如果我们直接传入字符串的引用,代码如下:

std::string remove_ctrl_ref_args(std::string const& s) {     
    std::string result;     
    result.reserve(s.length());     
    for (int i=0; i<s.length(); ++i) {         
        if (s[i] >= 0x20)             
            result += s[i];     
    }     
    return result; 
}

OutPut:

test Remove_ctrl_Ref_Args function : start
test Remove_ctrl_Ref_Args function : stop
Total take 16  ticks and takes 0  mS

使用迭代器操作

        在原书中提到,单纯使用传递引用的方式并没有减少时间(测试结果其实是减少了一部分时间。),他提到说这样的原因是因为引用是通过指针的方式实现的,在调用 s[i] 这个操作的时候,会进行两次解引用,从而导致速度下降,所以作者又提出了使用迭代器进行操作的测试:

std::string remove_ctrl_ref_args_it(std::string const& s) {     
    std::string result;     
    result.reserve(s.length()); 
    for (auto it=s.begin(),end=s.end(); it != end; ++it) {
        if (*it >= 0x20)
            result += *it;
    }    
    return result; 
}

OutPut:

test Remove_ctrl_Ref_Args_It function : start
test Remove_ctrl_Ref_Args_It function : stop
Total take 21  ticks and takes 0  mS

        测试结果没有像书中说的那样速度又快了一个数量级,这个结果反而比上面的方式还要慢一点。

减少循环中的比较操作

        在循环中,每次循环都会执行一步比较操作 i<s.length() ,而这样又增加函数调用的消耗,如果我们把这个数值缓存起来,进行比较,测试一下时间如何:

std::string remove_ctrl_ref_args_cacheSize(std::string const& s) {
    std::string result;
    auto _size = s.length();
    result.reserve(_size);
    for (int i=0; i < _size; ++i) {
        if (s[i] >= 0x20)
            result += s[i];
    }
    return result;
}

OutPut:

test Remove_ctrl_Ref_Args_CacheSize function : start
test Remove_ctrl_Ref_Args_CacheSize function : stop
Total take 13  ticks and takes 0  mS

        结果发现这样的时间更快了,但这样是不是就已经是极限了呢?不一定,我们还有一个返回值。如我们之前讨论的,当result作为返回值返回到调用函数处的时候,会发生字符串的复制赋值操作(也有可能是移动构造,不同的编译器处理可能不同),这样有可能又会增加了运行时间。

减少返回值的复制

void remove_ctrl_ref_result_it (
        std::string& result,
        std::string const& s) {
    result.clear();
    result.reserve(s.length());
    for (auto it=s.begin(),end=s.end(); it != end; ++it) {
        if (*it >= 0x20)
            result += *it;
    }
}

OutPut:

test Remove_ctrl_Ref_Result_it  : start
test Remove_ctrl_Ref_Result_it  : stop
Total take 20  ticks and takes 0  mS                

        这样处理和相同的迭代器版本比较来说,快了5%,也算了有了一些优化。但是这样对函数的接口进行了修改,而且这样还会引起错误,那就是当函数的两个参数都是一个实参时。

        到此为止,看起来我们把原本的代码中从输入的参数,到执行的步骤,再到返回值都给进行了一遍优化,时间从172优化到了13,这算是一个很大的成果了,但是,到此为止了吗?

还没有结束,使用字符数组代替字符串

        当我们已经对函数的执行步骤进行了一整个的翻新之后,我们发现已经没有地方可以改动了,这时候我们就要盯着其中的某个细节处--string,如果我们不使用这个类,而是使用最原始的结构,字符数组,这样会有什么效果呢?代码如下:

void remove_ctrl_cstrings(char* destp, char const* srcp, size_t size) {
    for (size_t i=0; i<size; ++i) {
        if (srcp[i] >= 0x20)
            *destp++ = srcp[i];
    }
    *destp = 0;
}

OutPut:

test Remove_ctrl_Cstrings  : start
test Remove_ctrl_Cstrings  : stop
Total take 3  ticks and takes 0  mS

        可以看到,时间又变快了一个数量级,这次仅仅用了3 ticks。

        看起来这是一个很好的结果,但是我们要注意的一点就是,因为我们是在理想环境下进行的测试,测试这些函数的时候,每一个函数的是在一个for循环当中连续调用的,这可能会因为缓存而有很高的结果,但是在实际环境中,缓存会被不停的刷新,那时候的结果就可能和试验结果有所不同了。

再次优化字符串

        在这之前我们已经使用了很多种方法了,每一种方法产生了多少的加速,为什么能造成这样的结果,我们也都有解析过,至此,我们已经尝试了6种方法,看起来前前后后能改的地方都改了。真的没有其他的优化处了吗?

        性能优化不仅仅包括代码的优化,如果我们只从代码的维度进行性能优化,这样可能会有结果,可能也不会有结果,如果一段代码的热点部分是单纯的计算问题导致的,那我们优化代码可能会有效果吗?所以我们在性能优化的时候尝试多维度考虑问题,既然代码已经分析完了,那么算法呢?

尝试其他的算法

        通过使用字串的方式进行连接。最外层for循环确定字串的起始位置,内层for循环确定下一次的外层for循环的起始位置。

std::string remove_ctrl_block(std::string s) {
    std::string result;
    for (size_t b = 0, i = b, e = s.length(); b < e; b = i + 1) {
        for (i = b; i < e; ++i) {
            if (s[i] < 0x20)
                break;
        }
        result = result + s.substr(b, i - b);
    }
    return result;
}

OutPut:

test Remove_ctrl_Block  : start
test Remove_ctrl_Block  : stop
Total take 21  ticks and takes 0  mS
 

        这个算法的时间是21ticks,和未使用字符数组进行优化的方式相当,这说明算法的优化效果也是很不错的。

叠加以前的优化方式

  •         通过移除临时字符串的方式进行优化,代码如下
std::string remove_ctrl_block_mutate(std::string s) {
    std::string result;
    result.reserve(s.length());
    for (size_t b=0,i=b,e=s.length(); b < e; b = i+1) {
        for (i=b; i<e; ++i) {
            if (s[i] < 0x20) break;
        }
        result += s.substr(b,i-b);
    }
    return result;
}

OutPut:

test Remove_ctrl_Mutate  : start
test Remove_ctrl_Mutate  : stop
Total take 9  ticks and takes 0  mS

  •         通过预留空间以及使用append方式进行优化
std::string remove_ctrl_block_append(std::string s) {
    std::string result;
    result.reserve(s.length());
    for (size_t b=0,i=b; b < s.length(); b = i+1) {
        for (i=b; i<s.length(); ++i) {
            if (s[i] < 0x20)
                break;
        }
        result.append(s, b, i-b);
    }
    return result;
}

OutPut:

test Remove_ctrl_Append  : start
test Remove_ctrl_Append  : stop
Total take 11  ticks and takes 0  mS

  •         通过传递引用方式进行优化
std::string remove_ctrl_block_args(std::string& s) {
    std::string result;
    result.reserve(s.length());
    for (size_t b=0,i=b; b < s.length(); b = i+1) {
        for (i=b; i<s.length(); ++i) {
            if (s[i] < 0x20)
                break;
        }
        result.append(s, b, i-b);
    }
    return result;
}
  •         移除返回值
void remove_ctrl_block_ret_it(std::string& result, std::string const& s) {
    result.clear();
    result.reserve(s.length());
    for (auto b=s.begin(),i=b,e=s.end(); b != e; b = i+1) {
        for (i=b; i != e; ++i) {
            if (*i < 0x20) 
                break;
        }
        result.append(b, i);
    }
}

Output:

test Remove_ctrl_Block_Ret_It  : start
test Remove_ctrl_Block_Ret_It  : stop
Total take 17  ticks and takes 0  mS

  •         移除指定字符
std::string remove_ctrl_erase(std::string s) {
    for (size_t i = 0; i < s.length(); )
        if (s[i] < 0x20)
            s.erase(i,1);
        else ++i;
    return s;
}

OutPut:

test Remove_ctrl_Erase  : start
test Remove_ctrl_Erase  : stop
Total take 13  ticks and takes 0  mS

  •         使用迭代器移除指定字符
std::string remove_ctrl_erase_it(std::string s) {
    for (auto i = s.begin(); i != s.end(); )
        if (*i < 0x20)
            s.erase(i);
        else ++i;
    return s;
}

OutPut:

test Remove_ctrl_Erase_It  : start
test Remove_ctrl_Erase_It  : stop
Total take 20  ticks and takes 0  mS

        总结下来,在不同的方法下有不同的效果,但是具体什么样的方式效果更好,则要看具体的平台、编译器、编译器版本、c++标准等。

使用其他的编译器

        之前的文章中有提到过,不同的编译器是不同的团队开发维护的,所以每个编译器的代码生成的效率可能都会有不同。如下是几种常见的编译器:

        Top C++ Compilers - Incredibuild

        这篇文章中介绍了多种编译器,包括它们的一些特性。有兴趣的可以尝试一下。

使用其他字符串的库

        就像在 尝试优化字符串 这个部分中讲过的,如果将string换成其他的实现方式,比如换成了字符数组,对性能的提升有了很大的帮助,那么使用其他的string的实现,是否会有一些帮助呢?

功能丰富的字符串库

        以下是几种更能更丰富的字符串库:

  • Boost字符串库(http://www.boost.org/doc/libs/?view=category_String)Boost字符串库提供了按标记将字符串分段、格式化字符串和其他操作std::string的函数。这为那些喜爱标准库中的<algorithm>头文件的开发人员提供了很大的帮助。
  • C++字符串工具包(http://www.partow.net/programming/strtk/index.html)另一个选择是C++字符串工具包(StrTk)。StrTk在解析字符串和按标记将字符串分段方面格外优秀,而且它兼容std::string。

使用std::stringstream避免值语义

        也可以尝试使用流式字符串进行优化。由于stringstream重写了"<<"操作符,所以在向字符串中插入字符的时候不需要临时的数据进行保存,因此也可以减少内存的分配和复制操作。但stringstream内部的缓冲区还是需要重分配的。

自己实现一种新的字符串

        首先如果我们想要完全的实现一种足以在所有场景替代string的实现,这不太现实,因为要想在全场景替代string,它需要满足这些条件:

        • 任何想要取代std::string的实现方式都必须具有足够的表现力,且在大多数场合都比std::string更高效。提议的绝大多数可选实现方式都无法确保在多数情况下可以提高性能。

        • 将一个大型程序中出现的所有std::string都换成其他字符串是一项浩大的工程,而且无法确保这一定能提高性能。

        • 虽然有许多种可选的字符串概念被提出来了,而且有一些已经实现了,但是想要通过谷歌找到一种像std::string一样完整的、经过测试的、容易理解的字符串实现,却需要花费一些工夫。

        对于一个项目来说,在需要考虑性能优化的时候,将其中的string进行优化,是比在项目一开始就对string进行考虑的成本要高的,因为中途替换string会导致很多的不确定性,但是如果在项目中仅仅需要性能优化的部分,替换string,这样的方式应该还是可以的。

其他的字符串方式std::string_view

        我们可以实现一个类,其中包含一个指针,指向string的字符串数据,包含一个n,代表string的大小,所以它可以表示为一个专门用来访问string的类,所以在某些时候,它应该比string会高效,但是他也有一个问题,那就是它无法保证它所指向的string是否是有效的(如果string被析构它也无法知道)。

  • folly::fbstring(https://github.com/facebook/folly/blob/master/folly/docs/FBString.md)Folly是一个完整的代码库,它被Facebook用在了他们自己的服务器上。它包含了高度优化过的、可以直接替代std::string的fbstring。在fbstring的实现方式中,对于短的字符串是不用分配缓冲区的。fbstring的设计人员声称他们测量到性能得到了改善。由于这种特性,Folly很可能非常健壮和完整。目前,只有Linux支持Folly。
  • 字符串类的工具包(http://johnpanzer.com/tsc_cuj/ToolboxOfStrings.html)这篇发表于2000年的文章和代码描述了一个模板化的字符串类型,其接口与SGI5的std::string相同。它提供了一个固定最大长度的字符串类型和一个可变长度的字符串类型。这是模板元编程(template metaprogramming)魔法的一个代表作,但可能会让一些人费解。对于那些致力于设计更好的字符串类的开发人员来说,这是一个切实可行的候选类库。
  • C++03表达式模板(http://craighenderson.co.uk/papers/exptempl/)这是在2005年的一篇论文中展示的用于解决特定字符串连接问题的模板代码。表达式模板重写了+运算符,这样可以创建一个表示两个字符串的连接或是一个字符串和一个字符串表达式的连接的中间类型。当表达式模板被赋值给一个字符串时,表达式模板将内存分配和复制推迟至表达式结束,只会执行一次内存分配。表达式模版兼容std::string。当既存的代码中有一个连接一长串子字符串的表达式时,使用表达式模板可以显著地提升性能。这个概念可以扩展至整个字符串库。
  • Better String库(http://bstring.sourceforge.net/)这个代码归档文件中包含了一个通用的字符串实现。它与std::string的实现方式不同,但是包含一些强大的特征。如果许多字符串是从其他字符串中的一部分构建出来的,bstring允许通过相对一个字符串的偏移量和长度来组成一个新的字符串。我用过以这种思想设计实现的有专利权的字符串,它们确实非常高效。在C++中有一个称为CBString的bstring库的包装类。
  • rope<T,alloc>(https://www.sgi.com/tech/stl/Rope.html)这是一个非常适合在长字符串中进行插入和删除操作的字符串库。它不兼容std::string。
  • Boost字符串算法(http://www.boost.org/doc/libs/1_60_0/doc/html/string_algo.html)这是一个字符串算法库,它是对std::string的成员函数的补充。这个库是基于“查找和替换”的概念构建起来的。                

        有兴趣的可以使用上述的字符串库进行尝试。

用更好的内存分配器

        之前的文章中提到过很多次,那就是内存分配器,查看string的源码我们可以看到

  template<typename _CharT, typename _Traits, typename _Alloc>
    const typename basic_string<_CharT, _Traits, _Alloc>::size_type
    basic_string<_CharT, _Traits, _Alloc>::npos;

_CharT  是内部存储的数据类型
_Traits   这个是抽象出来的可用操作和函数
_Alloc    这个是内存分配器

        我们直接看第三个_Alloc,分配器使C++内存管理器的专用接口,默认情况下使用的是std::alloctor,但是这个是一个通用的内存分配器,在性能上因为通用而作出了一些妥协。我们可以自己实现一个alloctor来尝试性能优化。

        部分代码,详细代码可以看我的另一个博客,我会专门将一下如何通过阅读string的源码写一个自己的alloctor。

typedef std::basic_string<char,
        std::char_traits<char>,
        blockAlloctor<char,10>>
        fixed_block_string;

// 原始版本的函数,只是更换了内存分配器
fixed_block_string remove_ctrl_fixed_block(std::string s) {
    fixed_block_string result;
    for (size_t i=0; i<s.length(); ++i) {
        if (s[i] >= 0x20)
            result = result + s[i];
    }
    return result;
}

OutPut:

test Remove_ctrl_Fixed_Block  : start
test Remove_ctrl_Fixed_Block  : stop
Total take 750  ticks and takes 0  mS

        很不幸,在我的机器上面这个实现的速度非常慢。但是我测试了release版本的代码,发现release版本的代码比初始版本要快3倍。

        如果我们希望在全局中都使用自己定义的这个字符串的话,我们应该使用

typedef std::string MyProjString;

这样的方式。

消除字符串的类型转换

        字符串的类型有很多中,在程序当中难免要使用不同的字符串进行操作,当不同的字符串进行比较、复制、赋值之类的操作时,字符串的类型转换就可能会发生。在任何时候,只要涉及到了复制字符或者动态内存分配,这些都是程序中可以优化的点。

        同时,将字符串进行转化的库函数自身也可以被转化。

将C字符串转换为std::string

        C风格的字符串是一个字符串,但是系统会给它加一个'\0'作为结尾的连续的char类型的数据,从C风格的字符串到string类型的转换,有时候是一些无谓的转换操作。如下代码:

std::string MyClass::getName() const {     
    return "MyClass"; 
}

        这个函数的每次运行,都会执行一遍从C风格字符串到string的类型转换,首先生成的是临时的C字符串"MyClass", 然后通过std::string(const char*)的构造函数进行构造一个std::string的实例,但是这一步无论在函数调用处使用,还是在函数返回值的时候转换,起始编译器都是会默认对其进行转换的,所以我们可以尝试将这个转换推迟,当真正需要转换的时候再进行转换,比如这样子:

char const* MyClass::getName() const {     
    return "MyClass"; 
}

        当真正需要一个std::string类型的时候,编译器才会对其进行转换,否则就不会进行转换。比如以下的情况:

char const* p = myInstance->Name(); // 没有转换
std::string s = myInstance->Name(); // 转换为'std::string' 
std::cout << myInstance->Name();    // 没有转换

不同字符集间的转换

        现代C++程序需要将C的字面字符串(ASCII,有符号字节)与来自Web浏览器的UTF-8(无符号,每个字符都是可变长字节)字符串进行比较,或是将由生成UTF-16的字流(带或者不带端字节)的XML解析器输出的字符串转换为UTF-8。转换组合的数量令人生畏。

        移除转换的最佳方法是为所有的字符串选择一种固定的格式,并将所有字符串都存储为这种格式。你可能希望提供一个特殊的比较函数,用于比较你所选择的格式和C风格的以空字符结尾的字符串,这样就无需进行字符串转换。我个人比较喜欢UTF-8,因为它能够表示所有的Unicode代码点,可以直接与C风格的字符串进行比较(是否相同),而且多数浏览器都可以输出这种格式。

        在时间紧迫的情况下编写的大型程序中,你可能会发现在将一个字符串从软件中的一层传递给另一层时,先将它从原来的格式转换为一种新的格式,然后再将它转换为原来的格式的代码。可以通过重写类接口中的成员函数,让它们接收相同的字符串类型来解决这个问题。不幸的是,这项任务就像是在C++程序中加入常量正确性(const-correctness)。这种修改涉及程序中的许多地方,难以控制其范围。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值