C#-面向对象:string还是StringBuilder?

我们之前故意对string一带而过,是因为它非常特别。首先,它是

imutable(不可变的)

即一个string定义的字符串,一旦设定,就不能改变。

在string上直接F12转到定义,就可以看到String类的成员。其中,只有一个只读的静态成员Empty(建议使用,以替代null和空字符串""):

public static readonly String Empty;

一个只读的索引器,可以获取字符串中某个下标的字符:

public char this[int index] { get; }

一个只读的属性Length,可以获取字符串长度:

public int Length { get; }

全部都是只读的,没有任何一个可写的类成员。再看看它的方法,有几个和“修改”相关的:

string slagon = "@大神小班,拎包入住@"; //删除 Console.WriteLine(slagon.Remove(1)); //结果:@ Console.WriteLine(slagon.Remove(1, 1)); //结果:@神小班,拎包入住@ //插入 Console.WriteLine(slagon.Insert(2, "@")); //结果:@大@神小班,拎包入住@ //替换 Console.WriteLine(slagon.Replace('大', '小')); //结果:@小神小班,拎包入住@ //截取 Console.WriteLine(slagon.Substring(1, 3)); //结果:大神小 //注意:slagon还是没有变 Console.WriteLine(slagon); //结果:@大神小班,拎包入住@

以及修剪掉字符串前后空白字符的Trim()方法:

string fg = " 大 飞 哥 "; //使用一个@后缀显示效果 Console.WriteLine(fg.Trim() + "@"); //删除前后空白:大 飞 哥@ Console.WriteLine(fg.TrimStart() + "@"); //删除前面的空白:大 飞 哥 @ Console.WriteLine(fg.TrimEnd() + "@"); //删除后面的空白: 大 飞 哥@ //fg本身不会改变 Console.WriteLine(fg + "@"); // 大 飞 哥 @

还有英语字母大小写转换的方法:

string sql = "SQL"; Console.WriteLine(sql.ToLower()); //变成小写:sql Console.WriteLine(sql); //不变:SQL string csharp = "CSharp"; Console.WriteLine(csharp.ToUpper()); //变成大学:CSHARP Console.WriteLine(csharp); //不变:CSharp

然而,所有的方法都不会改变作为参数传入的字符串slagan的值,因为字符串是immutable(不可变)的。同样,我们自己写一个方法:

static void say(string words) { words += "oh,yeah!"; }

然后进行调用:

say(slagon); Console.WriteLine(slagon); //slagon没有发生变化

运行的结果,slagon没变,“感觉”就像是传递了一个slagon的副本给方法say()一样——当然其实并不一样。

演示:传入say()方法的是一个堆地址

我们可以这样记忆:

  • string是引用类型
  • 但它是一个非常特殊的引用类型
  • 因为它在太多方面表现得和值类型一样(实质原因是imutable)

另一个更直接的证据:在string上直接F12转到定义,你就会发现:

public sealed class String

这说明string是一个类,类就是引用类型啊。注意它是sealed的,因为.NET不希望string被继承,以免破坏它的immutable特征。绝大多数时候,我们完全可以把string当做值类型来使用,不会有任何问题,这也更符合我们的惯常思维。

此外,string的比较运算符:

==

也设计得和值类型一样。

一般来说,如果是引用类型,==运算符会比较两个对象的堆地址;但值类型,==运算符直接比较两个对象的值。我们看看string的比较:

string center = "源栈", greet = "欢迎您"; string a = center + greet; string b = $"{center}{greet}"; Console.WriteLine(a == b); //结果为true

是不是像一个值类型一样?比较的是a和b里面存储的值。你看a和b的所存放的堆地址也不一样:

这就是通过我们之前讲过的运算符重载实现的:

public static bool operator ==(String a, String b);

你可能会问,为什么我们的示例代码这么复杂?又是拼接(+)又是内插($)的。因为如果这样的话会出问题:

string a = "源栈"; string b = "源栈"; Console.WriteLine(a == b);

代码的结果仍然是true,但是a和b存储的是相同的堆地址:

这就不能说明string的“值类型”特征,因为a和b变量中存储还是堆地址而不是字符串的值。你可能奇怪,明明没有任何传递,为什么a和b会引用同样的一个string对象呢?这又涉及一个编译器优化技术:

字符串池(string pool)

简单的说,在编译的时候(注意:是编译的时候,不是运行的时候),编译器会设置一个字符串“池(pool)”。每次要实例化一个新字符串的时候,首先在池中进行检查:

  • 如果池中已经有完全相同的字符串,直接将这个字符串的堆地址赋值给新变量;否则
  • 实例化这个字符串,然后放到字符串池里

这样,就可以节省很多的堆空间,尤其是当相同的字符串非常多的时候。

“池”这种优化技术,在.NET中大量使用,我们以后还会多次接触到。^_^

这时候,你可能会问:搞得这么复杂,为什么不把string直接改成struct呢?

这是因为string有可能非常非常大,像我们之前讲过的,太大的对象就不适合放在栈中,以免占用宝贵的栈资源。(复习:值类型和引用类型

最后,我们来看一看string的:

其他方法

一些方法可以进行判断。比如判断是否为空:

string a = null; string b = ""; string c = " "; //IsNullOrEmpty() :是不是为Null值或者为空 //IsNullOrWhiteSpace():是不是为Null或者空白字符串 Console.WriteLine(string.IsNullOrEmpty(a)); //True Console.WriteLine(string.IsNullOrWhiteSpace(a)); //True Console.WriteLine(string.IsNullOrEmpty(b)); //True Console.WriteLine(string.IsNullOrWhiteSpace(b)); //True Console.WriteLine(string.IsNullOrEmpty(c)); //False Console.WriteLine(string.IsNullOrWhiteSpace(c)); //True

以及其他返回bool值的判断,包含(Contain)和开始(Starts)/结束(Ends)

string slagon = "飞哥,还有源栈欢迎您!"; //包含"源栈" Console.WriteLine(slagon.Contains("源栈")); //True //以"飞哥"开始/"欢迎您!"结尾 Console.WriteLine(slagon.StartsWith("飞哥")); //True Console.WriteLine(slagon.EndsWith("欢迎您!")); //True

还有返回int类型下标的IndexOf()和LastIndexOf():

string slagon = "源栈欢迎您!!!"; Console.WriteLine(slagon.IndexOf("!")); //10 Console.WriteLine(slagon.LastIndexOf("!")); //12

此外,还有连接和拆分:

  • Contact()可以直接将字符串连接起来
  • Join()用指定字符(分隔符)将字符串连接起来

    string a = "源栈"; string b = ","; string c = "欢迎您!"; //直接把abc连接起来 Console.WriteLine(string.Concat(a, b, c)); //把abc用' '连接起来 string joined = string.Join(' ', a, b, c); Console.WriteLine(joined); //注意空格:源栈 , 欢迎您!

被Join()用分隔符连接起来的的字符串还可以再使用Split()拆分,获得一个string数组:

string[] splitted = joined.Split(' '); //用' '分隔 for (int i = 0; i < splitted.Length; i++) { Console.WriteLine(splitted[i]); } //被' '分隔之后,splitted共三个元素: //源栈 //, //欢迎您!

此外,还有一个可以把字符串转换成字符数组的:

char[] ofA = a.ToCharArray(); //结果:'源'和'栈'

这样转换之后,就可以对字符串的每个字符进行过滤筛选。

除了Join()和Contact()方法,其实我们更多的都是直接使用加号(+)直接连接字符串。然而,你或许看到有这样的建议:不要使用加号(+)进行字符串拼接,而应该使用:

StringBuilder

真的是这样么?同学们注意一定要警惕这样的“简洁明了”的论断。你可以反过来想一想:StringBuilder和string是同时推出的,如果“用加号(+)进行字符串拼接”真的不行,为什么微软要搞这么一个语法出来?

凡是涉及性能的问题,我们这已经是老生常谈了,一定要牢记几个原则:

  • 天下没有免费的午餐
  • 首先找到瓶颈
  • no profile no improvement

然后,不仅要知其然,更要知其所以然。

所以我们首先来看一看怎么使用它:

//实例化一个StringBuilder对象 StringBuilder sb = new StringBuilder(); //一直往StringBuilder对象上添加(Append)字符串 sb.Append("源栈"); sb.Append(","); sb.Append("欢迎您!"); //不要忘了使用ToString()将StringBuilder对象转换成字符串 string slagon = sb.ToString();

首先可以看出来,整个使用过程比较繁琐,如果用+的话就一行:

string slagon = "源栈" + "," + "欢迎您!";

然后StringBuilder是一个可以实例化的类,我们F12查看其定义,可以看到它的主要方法,除了Append(),还有:

  • Insert():插入,需要指定插入的位置index
  • Replace():替换
  • Remove():删除指定位置index,一定长度length的内容
  • Clear():清除全部内容

    sb.Remove(0, 1); //删除了从下标为0开始的一个字符 sb.Replace("!", "……"); //将!替换成…… sb.Insert(0, "○"); //在下标为0的地方插入一个○ //sb.Clear(); //全部清除

最后,它有好几个重载的构造函数。其中最重要的参数有两个:

public StringBuilder(int capacity); public StringBuilder(string value);

  • value:指定StringBuilder最开始“装”着的字符串
  • capacity:指定StringBuilder最初的“容量”。这实际上就是的StringBuilder所谓“性能提升”的关键。

我们来看看加号(+)拼接和StringBuilder.Append()的

区别

在C#中,虽说字符串被称之为“串”,但其他它不是像链表那样把字符一个一个串起来的,而是由一个字符数组予以存放。

演示:查看源代码

所以,两个字符串a和b的加号拼接的过程应该是这样的:

  1. 计算出a和b的长度,然后相加达到总长度
  2. 按总长度新生成一个新的char[]数组
  3. 将a和b的内容依次复制到新的char[]数组
  4. 将新的char[]数组合成字符串

注意,正常来说(因为不排除编译器优化的可能),不要以为三个字符串的拼接.NET就可以计算出a+b+c的长度,然后按上面的那样做,它还是得先完成a+b的拼接,再进行a+b和c的拼接。即使a+b+c可以优化,但这样的for循环代码也是非常常见的:

string[] students = { "王新", "陈元", "彭志强" }; for (int i = 1; i < students.Length; i++) { students[0] += students[i]; }

这总不能编译器优化了吧?那么如果拼接的次数多了,比如说100次,就会产生99次没有必要的字符串长度计算,99次没有必要的内存划分(需要new一个新的char[]),99(?)次没有必要的字符串复制……

为了节省掉这些不必要的性能损耗,.NET为我们提供了StringBuilder,其工作原理是:

  1. 在StringBuilder实例化的时候,生成一个长度(capacity)或者由构造函数参数指定,或者默认为16的char[]数组
  2. 将a、b、c、d……等字符串依次往char[]数组里装,如果
  3. char[]数组的长度不够了,StringBuilder自动扩充其capacity,生成一个双倍长度的新数组继续装
  4. 直到调用ToString(),将char[]数组转换成字符串

对比如下图所示:

所以,如果是大规模的字符串拼接,使用StringBuilder确实有性能上的优势。那么为什么加号拼接仍然这么常见呢?^_^,当然是图方便啦!因为当拼接的次数不多,拼接也没有形成性能瓶颈的时候,开发效率是第一位的——好吧,我承认,就是懒,哈哈。

但是,你也要明白一点:懒,并不一定是坏事。尤其是对于程序员而言。

作业:

  1. 确保文章(Article)的标题不能为null值,也不能为一个或多个空字符组成的字符串,而且如果标题前后有空格,也予以删除
  2. 设计一个适用的机制,能确保用户(User)的昵称(Name)不能含有admin、17bang、管理员等敏感词。
  3. 确保用户(User)的密码(Password):
  4. 长度不低于6
  5. 必须由大小写英语单词、数字和特殊符号(~!@#$%^&*()_+)组成
  6. 实现GetCount(string container, string target)方法,可以统计出container中有多少个target
  7. 不使用string自带的Join()方法,定义一个mimicJoin()方法,能将若干字符串用指定的分隔符连接起来,比如:mimicJoin("-","a","b","c","d"),其运行结果为:a-b-c-d

你可能感兴趣的:(c#)