System.String

作者:追风剑情 发布于:2021-2-4 9:15 分类:C#

System.Char 类处理字符。
System.String 类处理不可变(immutable)字符串(一经创建,字符串便不能以任何方式修改)。
System.Text.StringBuilder 类高效率地动态构造字符串。
System.Security.SecureString 类保护密码和信用卡资料等敏感字符串。
System.Globalization.UnicodeCategory 枚举定义了字符的Unicode类别。

字符串中嵌入变量
string s = $"xxxxxx{变量名}xxxx";

System.Char 类

示例——字符类型判断
char c = '1';
if (Char.IsDigit(c)) {
	Console.WriteLine("属于十进制数字类别");
}

//获取字符所属Unicode类别
UnicodeCategory category = Char.GetUnicodeCategory(c);
//输出 UnicodeCategory.DecimalDigitNumber
Console.WriteLine(category);

char a = 'A';
//转成小写 (转换时会使用与调用线程关联的语言文化信息)
//CultureInfo cultureInfo = Thread.CurrentThread.CurrentCulture;
Char.ToLower(a);
//转成小写 (忽略语言文化)
Char.ToLowerInvariant(a);

示例——Unicode码
//字母'A' 的 Unicode码 (UTF-32)
int letterA = 0x0041;
//Unicode码转UTF-16字符(可能生成2个utf16字符)
string s = Char.ConvertFromUtf32(letterA);
Console.WriteLine("0x{0:X4} => {1}", letterA, s);

//UTF-16转Unicode码
letterA = Char.ConvertToUtf32(s, 0);
Console.WriteLine("{0} => 0x{1:X4}", s, letterA);
以上代码执行结果为:
0x0041 => A
A => 0x0041

在字符编码术语中,码位或称编码位置,即英文的code point或code position,是组成码空间(或代码页)的数值。例如,ASCII码包含128个码位。

示例——字符比较
Char c1 = 'A', c2 = 'A';
//在两个Char实例代表同一个16位Unicode码位的前提下返回true
if (c1.Equals(c2)) //true
	Console.WriteLine("c1 == c2");

//返回两个Char实例的忽略语言文化的比较结果
Console.WriteLine(c1.CompareTo(c2)); //输出 0

示例——字符转数字
Double d;
// '\u0033' 是数字 '3'
d = Char.GetNumericValue('\u0033');//也可直接用 '3'
Console.WriteLine(d.ToString()); //显示 "3"

// '\u00bc'是普通分数四分之一
d = Char.GetNumericValue('\u00bc');
Console.WriteLine(d.ToString()); //显示 "0.25"

// 字母'A'
d = Char.GetNumericValue('A');
Console.WriteLine(d.ToString()); //显示 "-1"

示例——各种数值类型与Char实例的相互转换
Char c;
Int32 n;

// 通过C#转型(强制类型转换)实现数字与字符的相互转换
// 强制类型转换的效率最高,因为编译器会预先生成IL代码
c = (Char) 65;
Console.WriteLine(c); // 显示"A"

n = (Int32) c;
Console.WriteLine(n); // 显示"65"

// unchecked(对括号里的算术运算不检查溢出)
c = unchecked((Char) (65536 + 65));
Console.WriteLine(c); // 显示"A"

// 使用Convert实现数字与字符的相互转换
c = Convert.ToChar(65);
Console.WriteLine(c); // 显示"A"

n = Convert.ToInt32(c);
Console.WriteLine(n); // 显示"65"

// 演示Convert的范围检查
try {
	c = Convert.ToChar(70000); // 对16位来说过大
	Console.WriteLine(c); //不执行
} catch (OverflowException) {
	Console.WriteLine("Can't convert 70000 to a Char.");
}

// 使用IConvertible实现数字与字符的相互转换
c = ((IConvertible) 65).ToChar(null);
Console.WriteLine(c); // 显示"A"

n = ((IConvertible) c).ToInt32(null);
Console.WriteLine(n); // 显示"65"

checkedunchecked 关键字用来告诉编译器是否需要对算术运算进行溢出检查,也可以在Visual Studio中进行设置(【属性】->【生成】->【高级】)

111111.png

System.String 类

一个String代表一个不可变(immutable)的顺序字符集。String类型直接派生自Object,所以是引用类型。因此,String对象(它的字符数组)总是存在于堆上,永远不会跑到线程栈。String类型还实现了几个接口(IComparable/IComparable<String>,ICloneable,IConvertible,IEnumerable/IEnumerable<Char>IEquatable<String>)

如果使用不安全的(unsafe)代码,可以从一个 Char*SByte* 构造一个String。这时要使用C#的 new 操作符,并调用由String类型提供的、能接受Char*或SByte*参数的某个构造器。这些构造器将创建String对象,根据由Char实例或有符号(signed)字节构成的一个数组来初始化字符串。其他构造器则不允许接受任何指针参数,用任何托管编程语言写的安全(可验证)代码都能调用它们。

记住,除非指定了 /unsafe 编译器开关,否则C#代码必须是安全的或者说具有可验证性,确保代码不会引起安全风险和稳定性风险。

示例
//字面值(literal)字符串
//Environment.NewLine在Windows平台返回\r\n,在UNIX平台返回\n
String s1 = "Hi" + Environment.NewLine + "there.";

//会被编译器优化成 "Hi there"
String s2 = "Hi" + " " + "there.";

对非字面值字符串使用+操作符,连接则在运行时进行。运行时连接不要使用+操作符,因为这样会在堆上创建多个字符串对象,而堆是需要垃圾回收的,对性能有影响。相反,应该使用 System.Text.StringBuilder 类型。

最后,C#提供了一种特殊的字符串声明方式。采取这种方式,引号之间的所有字符会都被视为字符串的一部分。这种特殊声明称为“逐字字符串”(verbatim string),通常用于指定文件或目录的路径,或者与正则表达式配合使用。

示例
// 指定应用程序路径
String file = "C:\\Windows\\System32\\Notepad.exe";
// 使用逐字字符串指定应用程序路径
String file = @"C:\Windows\System32\Notepad.exe";

System.Globalization.CultureInfo 类型表示一个“语言/国家”对(根据 RFC 1766 标准)。例如,"en-US" 代表美国英语,"en-AU" 代表澳大利亚英语,而 "de-DE" 代表德国德语。

示例——比较
String s1 = "Strasse";
String s2 = "Straβe";
Boolean eq;

// Compare返回非零值
// StringComparison.Ordinal 序号比较(效率高)
eq = String.Compare(s1, s2, StringComparison.Ordinal) == 0;
Console.WriteLine("Ordinal comparison: '{0}' {2} '{1}'", s1, s2,
	eq ? "==" : "!=");

// 面向在德国(DE)说德语(de)的人群,
// 正确地比较字符串
CultureInfo ci = new CultureInfo("de-DE");
// Compare返回零值
eq = String.Compare(s1, s2, true, ci) == 0;
Console.WriteLine("Ordinal comparison: '{0}' {2} '{1}'", s1, s2,
	eq ? "==" : "!=");

考虑语言文化的比较

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Globalization;
using System.Threading;

namespace ConsoleApp26
{
    class Program
    {
        static void Main(string[] args)
        {
            String output = String.Empty;
            String[] symbol = new string[] { "<", "=", ">" };
            Int32 x;
            CultureInfo ci;

            // 以下代码演示了在不同语言文化中,
            // 字符串的比较方式也有所不同
            String s1 = "coté";
            String s2 = "cǒte";

            // 为法国法语排序字符串
            ci = new CultureInfo("fr-FR");
            x = Math.Sign(ci.CompareInfo.Compare(s1, s2));
            output += String.Format("{0} Compare: {1} {3} {2}",
                ci.Name, s1, s2, symbol[x + 1]);
            output += Environment.NewLine;

            // 为日本日语排序字符串
            ci = new CultureInfo("ja-JP");
            x = Math.Sign(ci.CompareInfo.Compare(s1, s2));
            output += String.Format("{0} Compare: {1} {3} {2}",
                ci.Name, s1, s2, symbol[x + 1]);
            output += Environment.NewLine + Environment.NewLine;

            // 以下代码演示了如何将CompareInfo.Compare的
            // 高级选项应用于两个日语字符串。
            // 一个字符串代表用平假名写成的单词"shinkansen"(新干线);
            // 另一个字符串代表用片假名写成的同一个单词
            s1 = "しんかんせん";//("\u3057\u3093\u304B\u3093\u305b\u3093")
            s2 = "シンカンセン";//("\u30b7\u30f3\u30ab\u30f3\u30bb\u30f3")

            // 以下是默认比较结果
            ci = new CultureInfo("ja-JP");
            x = Math.Sign(String.Compare(s1, s2, true, ci));
            output += String.Format("Simple {0} Compare: {1} {3} {2}",
                ci.Name, s1, s2, symbol[x + 1]);
            output += Environment.NewLine;

            // 以下是忽略日语假名的比较结果
            CompareInfo compareInfo = CompareInfo.GetCompareInfo("ja-JP");
            x = Math.Sign(compareInfo.Compare(s1, s2, CompareOptions.IgnoreKanaType));
            output += String.Format("Advanced {0} Compare: {1} {3} {2}",
                ci.Name, s1, s2, symbol[x + 1]);

            Console.WriteLine(output);

            Console.ReadLine();
        }
    }
}

运行测试
111111.png

除了CompareCompareInfo类还提供了IndexOf,LastIndexOf,IsPrefixIsSuffix方法。由于所有这些都提供了接受CompareOptions枚举值的重载版本,所以能提供比String类定义的Compare,IndexOf,LastIndexOf,StartsWithEndsWith方法更全面的控制。另外,FCL的System.StringComparer类也能执行字符串比较,它适合对大量不同的字符串反复执行同一种比较。

字符串留用

检查字符串相等性是应用程序的常见操作,也是一种可能严重损害性能的操作。执行序号(ordinal)相等性检查时,CLR快速测试两个字符串是否包含相同数量的字符。答案否定,字符串肯定不相等;答案肯定,字符串则可能相等。然后,CLR必须比较每个单独的字符才能最终确认。而执行对语言文化敏感的比较时,CLR必须比较所有单独的字符,因为两个字符串即使长度不同也可能相等。

此外,在内存中复制同一个字符串的多个实例纯属浪费,因为字符串是“不可变”(immutable)的。在内存中只保留字符串的一个实例将显著提升内存的利用率。需要引用字符串的所有变量只需指向单独一个字符串对象。

如果应用程序经常对字符串进行区分大小写的序号比较,或者事先知道许多字符串对象都有相同的值,就可利用 CLR 的字符串留用(string interning)机制来显著提升性能。CLR 初始化时会创建一个内部哈希表。在这个表中,键(key)是字符串,而值(value)是对托管堆中的 String对象的引用。哈希表最开始是空的(理应如此),String 类提供了两个方法,便于你访问这个内部哈希表:
public static String Intern(String str);
public static String IsInterned(String str);
第一个方法 Intern 获取一个 String,获得它的哈希码,并在内部哈希表中检查是否有相匹配的。如果存在完全相同的字符串,就返回对现有 String 对象的引用。如果不存在完全相同的字符串,就创建字符串的副本,将副本添加到内部哈希表中,返回对该副本的引用。如果应用程序不再保持对原始 String 对象的引用,垃圾回收器就可释放那个字符串的内存。注意垃圾回收器不能释放内部哈希表引用的字符串,因为哈希表正在容纳对它们的引用。除非卸载 AppDomain 或进程终止,否则内部哈希表引用的 String 对象不能被释放。

Intern 方法一样,IsInterned 方法也获取一个 String,并在内部哈希表中查找它。如果哈希表中有匹配的字符串,IsInterned 就返回对这个留用(interned)字符串对象的引用。但如果没有,IsInterned 会返回 null,不会将字符串添加到哈希表中。

程序集加载时,CLR默认留用程序集的元数据中描述的所有字面值(literal)字符串.Microsoft知道可能因为额外的哈希表查找而显著影响性能,所以现在能禁用此功能。如果程序集用 System.Runtime.CompilerServices.CompilationRelaxationsAttribute 进行了标记,并指定了 System.Runtime.CompilerServices.CompilationRelaxations.NoStringInterning 标志值,那么根据 ECMA 规范,CLR 可能选择不留用那个程序集的元数据中定义的所有字符串。注意,为了提升应用程序性能,C#编译器在编译程序集时总是指定上述两个特性和标志。

即使程序集指定了这些特性和标志,CLR 也可能选择对字符串进行留用,但不要依赖CLR的这个行为。事实上,除非显式调用String 的 Intern 方法,否则永远都不要以“字符串已留用”为前提来写代码。以下代码演示了字符串留用:

示例——字符串留用
String s1 = "Hello";
String s2 = "Hello";
Console.WriteLine(Object.ReferenceEquals(s1, s2)); //显示'False'

s1 = String.Intern(s1);
s2 = String.Intern(s2);
Console.WriteLine(Object.ReferenceEquals(s1, s2)); //显示'True'

在第一个ReferenceEquals方法调用中,s1引用堆中的"Hello"字符串对象,而s2引用堆中的另一个"Hello"对象。由于引用不同,所以应该显示False。但在CLR的4.5版本上运行,实际显示的是True。这是由于这个版本的CLR选择忽视C#编译器插入的特性和标志。程序集加载到AppDomain中时,CLR对字面值(literal)字符串"Hello"进行留用,结果是s1和s2引用堆中的同一个"Hello"字符串。但如前所述,你的代码永远不要依赖这个行为,因为未来版本的CLR有可能会重视这些特性和标志,从而不对"Hello"字符串进行留用。事实上,使用NGen.exe实用程序编译这个程序集的代码,CLR的4.5版本确实会使用这些特性和标志。

在第二个ReferenceEquals方法调用之前,"Hello"字符串被显示留用,s1现在引用已留用的"Hello"。然后,通过再次调用Intern,s2引用和s1一样的"Hello"字符串。所以第二个ReferenceEquals调用保证结果是True,无论程序集在编译时是否设置了特性和标志。

示例——字符串留用
//普通版
private static Int32 NumTimesWordAppearsEquals(String word, String[] wordlist) {
	Int32 count = 0;
	for (Int32 wordnum = 0; wordnum < wordlist.Length; wordnum++) {
		//Equals比较速度很慢(逐字符比较)
		if (word.Equals(wordlist[wordnum], StringComparison.Ordinal))
			count++;
	}
	return count;
}

//字符串留用版
private static Int32 NumTimesWordAppearsIntern(String word, String[] wordlist) {
	//这个方法假定wordlist中的所有数组元素都引用已留用的字符串
	word = String.Intern(word);
	Int32 count = 0;
	for (Int32 wordnum = 0; wordnum < wordlist.Length; wordnum++) {
		//只需要比较引用(指针)所以速度很快
		if (Object.ReferenceEquals(word, wordlist[wordnum]))
			count++;
	}
	return count;
}

字符串池

编译源代码时,编译器必须处理每个字面值(literal)字符串,并在托管模块的元数据中嵌入。同一个字符串在源代码中多次出现,把它们都嵌入元数据会使生成的文件无谓地增大。

为了解决这个问题,许多编译器(包括 C#编译器)只在模块的元数据中只将字面值字符串写入一次。引用该字符串的所有代码都被修改成引用元数据中的同一个字符串。编译器将单个字符串的多个实例合并成一个实例,能显著减少模块的大小。但这并不是新技术,C/C++编译器多年来一直在采用这个技术(Microsoft 的 C/C++编译器称之为“字符串池”)。尽管如此,字符串池仍是提升字符串性能的另一种行之有效的方式,而你应注意到它的存在。

检查字符串中的字符和文本元素

虽然字符串比较对于排序或测试相等性很有用,但有时只是想检查一下字符串中的字符。String类型为此提供了几个属性和方法,包括 Length,Chars(一个 C#索引器),GetEnumerator,ToCharArray, Contains, IndexOf, LastIndexOf, IndexOfAnyLastIndexOfAny

System.Char 实际代表一个 16 位 Unicode 码值,而且该值不一定就等于一个抽象 Unicode字符。例如,有的抽象 Unicode 字符是两个码值的组合。U+0625(阿拉伯字母 Alef with Hamzabelow)和 U+0650(Arabic Kasra)字符组合起来就构成了一个抽象字符或者文本元素(text element)。

除此之外,有的Unicode 文本元素要求用两个16位值表示。第一个称为“高位代理项”(highsurrogate),第二个称为“低位代理项”(low surrogate)。其中,高位代理项范围在 U+D800到 U+DBFF 之间,低位代理项范围在 U+DC00 到 U+DFFF 之间。有了代理项,Unicode就能表示 100 万个以上不同的字符。

美国和欧洲很少使用代理项,东亚各国则很常用。为了正确处理文本元素,应当使用System.Globalization.StringInfo 类型。使用这个类型最简单的方式就是构造它的实例,向构造器传递一个字符串。然后可以查询 StringInfoLengthInTextElements 属性来了解字符串中有多少个文本元素。接着就可以调用 StringInfoSubstringByTextElements 方法来提取所有文本元素,或者提取指定数量的连续文本元素。

StringInfo类还提供了静态方法GetTextElementEnumerator,它返回一个System.Globalization.TextElementEnumerator对象,允许枚举字符串中包含的所有抽象Unicode字符。最后,可调用StringInfo的静态方法ParseCombiningCharacters来返回一个Int32数组。从数组长度就能知道字符串包含多少个文本元素。每个数组元素都是一个文本元素的起始码值索引。

以下代码演示了使用StringInfo类来处理字符串中的文本元素的各种方式:

using System;
using System.Text;
using System.Globalization;

namespace ConsoleApp27
{
    class Program
    {
        static void Main(string[] args)
        {
            Encoding originalOutputEncoding = Console.OutputEncoding;
            Console.OutputEncoding = Encoding.Unicode;

            // 以下字符串包含组合字符
            String s = "a\u0304\u0308bc\u0327";
            SubstringByTextElements(s);
            EnumTextElements(s);
            EnumTextElementIndexes(s);

            Console.OutputEncoding = originalOutputEncoding;
            Console.ReadLine();
        }

        private static void SubstringByTextElements(String s)
        {
            String output = String.Empty;
            StringInfo si = new StringInfo(s);
            for (Int32 element = 0; element < si.LengthInTextElements; element++)
            {
                output += String.Format("Text element {0} is '{1}'{2}",
                    element, si.SubstringByTextElements(element, 1),
                    Environment.NewLine);
            }
            Console.WriteLine("SubstringByTextElements:");
            //Normalize()
            //包含组合字符序列 U+0061 U+0308 的字符串在输出字符串之前以
            //两个字符的形式显示在控制台上,并在调用方法后作为单个字符显示
            Console.WriteLine(output.Normalize());
        }

        private static void EnumTextElements(String s)
        {
            String output = String.Empty;
            TextElementEnumerator charEnum = StringInfo.GetTextElementEnumerator(s);
            while (charEnum.MoveNext())
            {
                output += String.Format("Character at index {0} is '{1}'{2}",
                    charEnum.ElementIndex, charEnum.GetTextElement(),
                    Environment.NewLine);
            }
            Console.WriteLine("EnumTextElements:");
            Console.WriteLine(output.Normalize());
        }

        private static void EnumTextElementIndexes(String s)
        {
            String output = String.Empty;
            Int32[] textElemIndex = StringInfo.ParseCombiningCharacters(s);
            for (Int32 i = 0; i < textElemIndex.Length; i++)
            {
                output += String.Format("Character {0} starts at index {1}{2}",
                    i, textElemIndex[i], Environment.NewLine);
            }
            Console.WriteLine("EnumTextElementIndexes:");
            Console.WriteLine(output.Normalize());
        }
    }
}
11111.png


其他字符串操作

用于复制字符串的方法
成员名称 方法类型 说明
Clone 实例 返回对同一个对象(this)的引用。能这样做是因为String对象不可变(immutable)。该方法实现了String的ICloneable接口
Copy 静态 返回指定字符串的新副本。该方法很少用,它的存在只是为了帮助一些需要把字符串当作token来对待的应用程序。通常,包含相同字符内容的多个字符串会被“留用”(intern)为单个字符串。该方法创建新字符串对象,确保即使字符串包含相同字符内容,引用(指针)也有所不同。
CopyTo 实例 将字符串中的部分字符复制到一个字符数组中
Substring 实例 返回代表原始字符串一部分的新字符串
ToString 实例 返回对同一个对象(this)的引用

除了这些方法,String还提供了多个用于处理字符串的静态方法和实例方法,比如Insert,Remove,PadLeft,Replace,Split,Join,ToLower,ToUpper,Trim,Concat,Format等。使用所有这些方法时都请牢记一点,它们返回的都是新的字符串对象。这是由于字符串是不可变的。一经创建,便不能修改(使用安全代码的话)。

Visual Studio 调试器中,鼠标移到变量上方会出现一条数据提示(datatip)。提示中的文本正是通过调用对象的 ToString 方法来获取的。所以,定义类时应该总是重写 ToString 方法,以提供良好的调试支持。

指定具体的格式和语言文化

无参 ToString 方法有两个问题。首先,调用者无法控制字符串的格式。例如,应用程序可能需要将数字格式化成货币、十进制、百分比或者十六进制字符串。其次,调用者不能方便地选择一种特定语言文化来格式化字符串。相较于客户端代码,服务器端应用程序在第二个问题上尤其麻烦。极少数时候,应用程序需要使用与调用线程不同的语言文化来格式化字符串。为了对字符串格式进行更多的控制,你重写的 ToString 方法应该允许指定具体的格式和语言文化信息。
为了使调用者能选择格式和语言文化,类型应该实现 System.IFormattable 接口:
public interface IFormattable {
 String ToString(String format, IFormatProvider formatProvider);
}
FCL的所有基类型(Byte,SByte,Int16/UInt16, Int32/UInt32, Int64/UInt64, Single, Double,Decimal 和 DateTime)都实现了这个接口。此外,还有另一些类型(比如 Guid)也实现了它。最后,每个枚举类型定义都自动实现 IFormattable 接口,以便从枚举类型的实例获取一个有意义的字符串符号。

IFormattable 的 ToString 方法获取两个参数。第一个是 format,这个特殊字符串告诉方法应该如何格式化对象。第二个是 formatProvider,是实现了 System.IFormatProvider 接的一个类型的实例。该类型为 ToString 方法提供具体的语言文化信息。

实现 IFormattable 接口的 ToString 方法的类型决定哪些格式字符串能被识别。如果传递的格式字符串无法识别,类型应抛出 System.FormatException 异常。

Microsoft 在 FCL 中定义的许多类型都能同时识别几种格式。例如,DateTime 类型支持用“d”表示短日期,用“D”表示长日期,用“g”表示常规(general),用“M”表示月/日,用“s”表示可排序(sortable),用“T”表示长时间,用“u”表示 ISO 8601 格式的协调世界时,用“U”表示长日期格式的协调世界时,用“Y”表示年/月。所有枚举类型都支持用“G”表示常规,用“F”表示标志(Flag),用“D”表示十进制,用“X”表示十六进制。

此外,所有内建数值类型都支持用“C”表示货币格式,用“D”表示十进制格式,用“E”表示科学记数法(指数)格式,用“F”表示定点(fix-point)格式,用“G”表示常规格式,用“N”表示数字格式,用“P”表示百分比格式,用“R”表示往返行程(round-trip)格式,用“X”表示十六进制格式。事实上,数值类型还支持 picture 格式字符串,它是考虑到在某些时候,简单的格式字符串可能无法完全满足需求。picture 格式字符串包含一些特殊字符,它们告诉类型的 ToString 方法具体要显示多少个数位、具体在什么位置放置一个小数分隔符以及具体有多少位小数等。欲知详情,请查阅文档中的“自定义数字格式字符串”主题。

① 文档中的“标准数字格式字符串”一节对 R 符号(文档中称为说明符)的解释是:往返行程说明物保证转换为字符串的数值再次被分析为相同的数值。使用此说明符格式化数值时,首先使用常规格式对其进行测试:Double 使用 15 位精度,Single 使用 7 位精度。如果此值被成功地分析回相同的数值,则使用常规格式说明符对其进行格式化。但是,如果此值未被成功地分析为相同数值,则它这样格式化:Double 使用 17 位精度,Single 使用 9 位精度。虽然精度说明符可以附加到往返行程格式说明符,但它将被忽略。使用此说明符时,往返行程优先于精度。此格式仅有浮点型(Single 和 Double支持。——译注

② pictue 确实可以理解成“图片”。例如,使用 picture数值格式字符串####可以显示千分位分隔符。换言之,像画图那样指定确切的显示格式。——译注

对于大多数类型,调用 ToString 并为格式字符串传递 null 值完全等价于调用 ToString 并为格式字符串传递“G”。换言之,对象默认使用“常规格式”对自身进行格式化。实现类型时,要选择自己认为最常用的一种格式;这个格式就是“常规格式”。顺便说一句无参的 ToString 方法假定调用者希望的是“常规格式”。

理解了格式字符串之后,接着研究一下语言文化的问题。字符串默认使用与调用线程关联的语言文化信息进行格式化。无参 ToString 方法就是这么做的;另外,为 formatProvider参数传递 null 值,IFormattable 的 ToString 方法也这么做。

格式化数字(货币、整数、浮点数、百分比、日期和时间)适合应用对语言文化敏感的信息。Guid 类型的 ToString 方法只返回代表 GUID 值的字符串。生成 Guid 的字符串时不必考滤语言文化,因为GUID只用于编程。

格式化数字时,ToString 方法检查为 formatProvider参数传递的值。如果传递 null, ToString读取 System.Globalization.CultureInfo.CurrentCulture 属性来判断与调用线程关联的语言文化。该属性返回 System.Globalization.CultureInfo 类型的一个实例。

利用这个对象,ToString会读取它的 NumberFormat或 DateTimeFormat 属性(具体取决于要格式化数字还是日期/时间)。这两个属性分别返回 System.Globalization.NumberFormatInfo或System.Globalization.DateTimeFormatiInfo 类型的实例。NumberFormatInfo 类型定义了 CurrencyDecimalSeparator, CurrencySymbol, NegativeSign, NumberGroupSeparator和PercentSymbol 等属性。而 DateTimeFormatInfo 类型定义了 Calendar, DateSeparator,DayNames,LongDatePattern,ShortTimePattern 和 TimeSeparator 等属性。ToSring会在构造并格式化字符串时读取这些属性。

调用 IFormattable 的 ToString 方法可以不传递 null,而是传递一个对象引用,该对象的类型实现了 IFormatProvider 接口:
public interface IFormatProvider {
  Object GetFormat(Type formatType);
}

IFormatProvider 接口的基本思路是:当一个类型实现了该接口,就认为该类型的实例能提供对语言文化敏感的格式信息,与调用线程关联的语言文化应被忽略。

FCL 的类型只有少数实现了 IFormatProvider 接口。System.Globalization.CultureInfo 类型就是其中之一。例如,要为越南地区格式化字符串,就需要构造一个 CultureInfo 对象,并将那个对象作为 ToString 的 formatProvider 参数来传递。以下代码将以越南地区适用的货币格式来获取一个 Decimal数值的字符串表示:
Decimal price = 123.54M;
String s = Price.Tostring("C", new CultureInfo("vi-vN"));
MessageBox.Show(s);
在内部, Decimal 的 ToString 方法发现 formatProvider实参不为 null,所以会像下面这样调用对象的 GetFormat 方法:
NumberFormatInfo nfi =(NumberPormatInfo)formatProvider.GetFormat(typeof(NumberpormatInfo));
ToString正是采取这种方式从 CultureInfo 对象获取恰当的数字格式信息。数值类型(比如Decimal)只请求数字格式信息。但其他类型(如 DateTime)可能像下面这样调用 GetFormat:
DateTimeFormatInfo dtfi = (DateTimeFormatInfo)formatProvider.GetFormat(typeof(DateTimeFormatInfo));

实际上,由于 GetFormat 的参数能标识任何类型,所以该方法非常灵活,能请求任意类型的格式信息。.NET Framework 中的类型在调用 GetFormat 时,暂时只会请求数字或日期/时间信息,但未来可能会请求其他格式信息。

顺便说一句,如果对象不针对任何具体的语言文化而格式化,那么为了获取它的字符串表示,应调用 System.Globalization.CultureInfo 的静态 InvariantCulture 属性,并将返回的对象作为 ToString 的 formatProvider 参数来传递:
Decimal price = 123.54M;
String s = price.ToString("c", CultureInfo.InvariantCulture);
MessageBox.Show(s);

InvariantCulture 格式化的字符串一般都不是向用户显示的。相反,一般将这种字符串保存到数据文件中供将来解析。

FCL 只有 3 个类型实现了 IFormatProvider 接口。第一个是前面解释过的 CultureInfo。另外两个是 NumberFormatInfo 和DateTimeFormatInfo。在 NumberFormatInfo 对象上调用GetFormat,方法会检查被请求的类型是不是一个 NumberFormatInfo。如果是就返回 this否则返回 null。类似地,在 DateTimeFormatInfo 对象上调用 GetFormat,如果请求的是一个DateTimeFormatInfo 就返回 this,否则返回null。这两个类型实现 IFormatProvider接口是为了简化编程。试图获取对象的字符串表示时,调用者通常要指定格式,并使用与调用线程关联的语言文化。因此,经常都要调用 ToString,为 format 参数传递一个字符串,并为 formatProvider 参数传递 null。为简化 ToString 调用,许多类型都提供了 ToString方法的多个重载版本。例如,Decimal 类型提供了 4 个不同的 ToString 方法:

//这个版本调用ToString(null, null)
//含义:采用常规数值格式,采用线程的语言文化信息
public override String ToString();

//这个版本是ToString 的真正实现
//这个版本实现了 IFormattable 的 Tostring 方法
//含义:采用由调用者指定的格式和语言文化信息
public String Tostring(string format, IFormatProvider formatProvider);

//这个版本简单地调用 ToString(format, null)
//含义:采用由调用者指定的格式,采用线程的语言文化信息
public String ToString(String format);

//这个版本简单地调用ToString(null, formatProvider)
//这个版本实现了IConvertible的ToString方法
//含义:采用常规格式,采用由调用者指定的语言文化信息
public String ToString(IFormatProvider formatProvider);

将多个对象格式化成一个字符串

到目前为止讲的都是一个单独的类型如何格式化它自己的对象。但有时需要构造由多个已格式化对象构成的字符串。例如,以下字符串由一个日期、一个人名和一个年龄构成:
String s = String.Format("On {0}, {1} is {2} years old.", new DateTime(2012, 4, 22, 14, 35, 5), "Aidan", 9);
Console.WriteLine(s);
生成并运行上述代码,而且“en-US”是线程当前的语言文化,就会看到以下输出:
On 4/22/2012 2:35:05 PM, Aidan is 9 years old.

String 的静态 Format 方法获取一个格式字符串。在格式字符串中,大括号中的数字指定了可供替换的参数。本例的格式字符串告诉 Format 方法将{0}替换成格式字符串之后的第一个参数(日期/时间),将{1}替换成格式字符串之后的第二个参数("Aidan"),将{2}替换成格式字符串之后的第三个参数(9)。

在内部,Format 方法会调用每个对象的 ToString 方法来获取对象的字符串表示。返回的字符串依次连接到一起,并返回最终的完整字符串。看起来不错,但它意味着所有对象都要使用它们的常规格式和调用线程的语言文化信息来格式化。

在大括号内指定格式信息,可以更全面地控制对象格式化。例如,以下代码和上例几乎完全一致,只是为可替换参数 0 和 2 添加了格式化信息:
String s = String.Format("On {0:D}, {1} is {2:E} years old.", new DateTime(2012, 4, 22, 14, 35, 5), "Aidan", 9);
Console.WriteLine(s);
生成并运行上述代码,同时“en-US”是线程当前的语言文化,会看到以下输出:
On Sunday, April 22, 2012, Aidan is 9.000000E+000 years old。

Format 方法解析格式字符串时,发现可替换参数 0 应该调用它的 IFormatable 接口的ToString方法,并为该方法的两个参数分别传递"D"和null.类似地,Format会调用可替换的参数2的 IFormattable 接口的 ToString 方法,并传递"E"和 null,假如可替换参数0和2的类型没有实现 IFormattable 接口,Format 会调用从 Objet继承(而且有可能重写)的无参 ToString 方法,并将默认格式附加到最终生成的字符串中。

String 类提供了静态 Format 方法的几个重载版本。一个版本获取实现了 IFormatProvider接口的对象,允许使用由调用者指定的语言文化信息来格式化所有可替换参数。显然,这个版本的 Format 会调用每个对象的 IFormattabte.ToString 方法,并将传给 Format 的任何IFormatProvider 对象传给它。

如果使用 StringBuider 而不是 String 来构造字符串,可以调用 StringBuilder 的AppendFormat 方法。它的原理与 String 的 Format 方法相似,只是会格式化字符串并将其附加到 StringBuilder 的字符数组中。和 String 的 Format 方法一样,AppendFormat也要获取格式字符串,而且也有获取一个 IFormatProvider 的版本。

System.Console 的 Write 和 WriteLine 方法也能获取格式字符串和可替换参数。但Console的 Write 和 WriteLine 方法没有重载版本能获取一个 IFormarProvider。要格式化符合特定语言文化的字符串,必须调用 String的 Format 方法,首先传递所需的 IFormatProvider对象,再将结果字符串传给 Console的 Write或 WriteLine 方法。但这应该不是一个大问题。正如前文所述,客户端代码极少需要使用有别于调用线程的其他语言文化来格式化字符串。

解析字符串来获取对象:Parse

示例——Parse
//获取当前线程使用的区域性
CultureInfo culture = CultureInfo.CurrentCulture;
NumberFormatInfo numberFormat = culture.NumberFormat;
//允许解析的数字字符串存在前导和结尾空白字符
NumberStyles style = NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite;
Int32 i = Int32.Parse(" 123 ", style, numberFormat);
//输出: zh-CN 123¥
Console.WriteLine("{0} {1}{2}", culture.Name, i, numberFormat.CurrencySymbol);

Parse 扮演了一个工厂(factory)的角色。在FCL中,所有数值类型、DateTime、TimeSpan以及其他一些类型(比如各种SQL数据类型)均提供了Parse方法。

CultureInfo 类
NumberStyles 枚举
NumberFormatInfo 类

提供定制格式化器

编码:字符和字节的相互转换

Win32 开发人员经常要写代码将 Unicode 字符和字符串转换成“多字节字符集”(Multi-Byte Character Set,MBCS)格式。我个人就经常写这样的代码,这个过程很繁琐,还容易出错。在 CLR 中,所有字符都表示成 16 位 Unicode 码值,而所有字符串都由 16 位 Unicode 码值构成,这简化了运行时的字符和字符串处理。

但偶尔也想要将字符串保存到文件中,或者通过网络传输。如果字符串中的大多数字符都是英语用户用的,那么保存或传输一系列 16 位值,效率就显得不那么理想,因为写入的半数字节都只由零构成。相反,更有效的做法是将 16 位值编码成压缩的字节数组,以后再将字节数组解码回 16 位值的数组。

这种编码技术还使托管应用程序能和非 Unicode 系统创建的字符串进行交互。例如,要生成能由 Windows 95 日文版上运行的应用程序读取的文件,必须使用 Shift-JIS(代码页932)保存 Unicode 文本。类似地,要用 Shift-JIS编码将 Windows 95 日文版生成的文本文件读入CLR。

System.IO.BinaryWriter 或者 System.IO.StreamWriter 类型将字符串发送给文件或网络流时,通常要进行编码。对应地,用 System.IO.BinaryReader 或者 System.IO.StreamReader 类型从文件或网络流中读取字符串时,通常要进行解码。不显式指定一种编码方案,所有这些类型都默认使用UTF-8(Unicode Transformation Format,即“Unicode转换格式”)。但有时还是需要显式编码或解码字符串。即使不需要显式编码和解码。也能通过本节的学习,对流中的字符串读写有一个更清醒的认识。

幸好,FCL提供了一些类型来简化字符編码和解码。两种最常用的编码方案是 UTF-16 和UTF-8,如下所述。

● UTF-16 将每个16位字符编码成2个字节。不对字符产生任何影响,也不发生压缩——性能非常出色。UTF-16 编码也称为“Unicode 编码”。还要注意,UTF-16可用于从"低位优先"(little-endian)转换成"高位优先"(big-endian),或者从"高位优先"转换成"低位优先"。

● UTF-8 将部分字符编码成1个字节,部分编码成2个字节,部分编码成3个字节,再有部分编码成4个字节。值在 0x0080 之下的字符压缩成1个字节,适合表示美国使用的字符。0x0080~0x07FF 的字符转换成2个字节,适合欧洲和中东语言。0x0800 以及之上的字符转换成3个字节,适合东亚(比如,汉字)语言。最后,代理项对(surrogate pair)表示成4个字节。UTF-8 编码方案非常流行,但如果要编码的许多字符都具有 0x0800 或者之上的值,效率反而不如 UTF-16。

UTF-16 和 UTF-8 编码是目前最常用的编码方案。FCL还支持下面这些不常用的。

● UTF-32 使用 4 个字节来编码所有字符。要写简单算法来遍历所有字符,同时不愿意花额外精力应付字节数可变的字符,就适合采用这种编码。例如,使用 UTF-32 根本不需要考虑代理项的问题,因为每个字符都是 4 字节。当然,UTF-32 的内存使用并不高效,所以很少用它将字符串保存到文件或者通过网络来传输字符串。这种编码方案通常在程序内部使用。还要注意,UTF-32 可用于“低位优先”和“高位优先”之间的相互转换。

● UTF-7 编码用于旧式系统。在那些系统上,字符可以使用7位值来表示。应该避免使用这种编码,因为它最终通常会使数据膨胀,而不是压缩。这种编码方案已被Unicode协会淘汰。

● ASCII 编码方案将 16 位字符編码成 ASCII 字符;也就是说,值小于 0x0080 的16位字符被转换成单字节。值超过 Ox007F 的任何字符都不能被转换,否则字符的值会丢失。假如字符串完全由 ASCII 范围(Ox00~0x7F)内的字符构成,ASCII 编码方案就能将数据压缩到原来的一半,而且速度非常快(高位字节被直接截掉)。但如果一些字符在ASCII 范围之外,这种编码方案就不适合了,因为字符的值会丢失。

最后,FCL 还允许将 16 位字符编码到任意代码页。和 ASCII 一样,编码到代码页也是危险的,因为代码页表示不了的任何字符都会丢失。除非必须和使用其他编码方案的遗留文件或应用程序兼容,否则应该总是选择 UTF-16 或 UTF-8 编码。

要编码或解码一组字符时,应获取从 System.Text.Encoding 派生的一个类的实例。抽象基类Encoding提供了几个静态只读属性,每个属性都返回从 Encoding 派生的一个类的实例。

下例使用 UTF-8 进行字符编码/解码。

示例——使用UTF-8进行字符编码/解码
using System;
using System.Text;

public static class Program {
	public static void Main() {
		// 准备编码的字符串
		String s = "Hi there.";
		
		// 获取从Encoding派生的一个对象,
		// 它知道怎样使用UTF-8来进行编码/解码
		Encoding encodingUTF8 = Encoding.UTF8;
		
		// 将字符串编码成字节数组
		Byte[] encodedBytes = encodingUTF8.GetBytes(s);
		
		// 显示编好码的字节值
		Console.WriteLine("Encoded bytes: " +
			BitConverter.ToString(encodedBytes));
		
		// 将字节数组解码回字符串
		String decodedString = encodingUTF8.GetString(encodedBytes);
		
		// 显示解码的字符串
		Console.WriteLine("Decoded string: " + decodedString);
	}
}
以上代码执行结果为:
Encoded bytes: 48-69-20-74-68-65-72-65-2E
Decoded string: Hi there.

除了 UTF8 静态属性,Encoding 类还提供了以下静态属性:Unicode,BigEndianUinicodeUTF32,UTF7,ASCII 和 Default。Default属性返回的对象使用用户当前的代码员来进行编码/解码。当前用户的代码页是在控制面板的“区域和语言选项”对话框中,通过“非Unicode 程序中所使用的当前语言”区域的选项来指定的(查阅 Win32 函数 GetACP 了解详情)。但不鼓励使用 Default 属性,因为这样一来,应用程序的行为就会随着机器的设置而变。也就是说,一旦更改系统默认代码页,或者应用程序在另一台机器上运行,应用程序的行为就会改变。

除了这些属性,Encoding 还提供了静态 GetEncoding 方法,允许指定代码页(整数或字符串形式),并返回可以使用指定代码页来编码/解码的对象。例如,可调用 GetEncoding 并传递 "Shift-JIS" 或者 932

首次请求一个编码对象时,Encoding 类的属性或者 GetEncoding 方法会为请求的编码方案构造对象,并返回该对象。假如请求的编码对象以前请求过,Encoding 类会直接返回之前构造好的对象;不会为每个请求都构造新对象。这一举措减少了系统中的对象数量,也缓解了堆的垃圾回收压力。

除了调用 Encoding 的某个静态属性或者它的 GetEncoding 方法,还可构造以下某个类的实例: System.Text.UnicodeEncoding, System.Text.UTF8Encoding, System.Text.UTF32Encoding,System.Text.UTF7Encoding 或者 System.Text.ASCIIEncoding。但要注意,构造任何这些类的实例都会在托管堆中创建新对象,对性能有损害。

其中 4 个类(UnicodeEncoding,UTF8Encoding, UTF32Encoding 和 UTF7Encoding)提供了多个构造器,允许对编码和前导码(reamble在文档中翻译成“前导码”,可通过Encoding.GetPreamble方法获取)进行更多的控制(前导码有时也称为“字节顺序标记”,即 Byte Order Mark 或者 BOM)。在这4个类中,前3个类还提供了特殊的构造器,允许在对一个无效的字节序列进行解码的时候抛出异常。如果需要保证应用程序的安全性,防范无效的输入数据,就应当使用这些能抛出异常的类。

处理 BinaryWriterStreamWriter 时,显式构造这些 Encoding 类型的实例是可以的。但ASCIIEncoding 类仅一个构造器,没有提供更多的编码控制,所以如果需要 ASCIIEncoding对象,请务必查询 Encoding 的 ASCII 属性来获得。该属性返回的是一个 ASCIIEncoding 对象引用。自己构造 ASCIIEncoding 对象会在堆上创建更多的对象,无谓地损害应用程序的性能。

一旦获得从 Encoding 派生的对象,就可调用 GetBytes 方法将字符串或字符数组转换成字节数组(GetBytes 有几个重载版本)。要将字节数组转换成字符数组或字符串,需要调用GetChars 方法或者更有用的 GetString 方法(这两个方法都有几个重载版本)。前面的示例代码演示了如何调用 GetBytes 和 GetString 方法。

从 Encoding 派生的所有类型都提供了 GetByteCount 方法,它能统计对一组字符进行编码所产生的字节数,同时不实际进行编码。虽然 GetByteCount 的用处不是很大,但在分配字节数组时还是可以用一下的。另有一个 GetCharCount 方法,它返回解码得到的字符数,同时不实际进行解码。要想节省内存和重用数组,可考虑使用这些方法。

GetByteCountGetCharCount 方法的速度一般,因为必须分析字符或字节数组才能返回准确的结果。如果更加追求速度而不是结果的准确性,可改为调用 GetMaxByteCountGetMaxCharCount 方法。这两个方法获取代表字符数或字节数的一个整数,返回最坏情况下的值。

从 Encoding 派生的每个对象都提供了一组公共只读属性,可查询这些属性来获取有关编码的详细信息。详情请参考文档。

以下程序演示了大多数属性及其含义,它将显示几个不同的编码的属性值:

示例
class Program
{
	static void Main(string[] args)
	{
		foreach (EncodingInfo ei in Encoding.GetEncodings())
		{
			Encoding e = ei.GetEncoding();
			Console.WriteLine("{1}{0}" + 
				"\tCodePage={2}, WindowsCodePage={3}{0}" +
				"\tWebName={4}, HeaderName={5}, BodyName={6}{0}" +
				"\tIsBrowserDisplay={7}, IsBrowserSave={8}{0}"+
				"\tIsMailNewsDisplay={9}, IsMailNewsSave={10}{0}",
				
				Environment.NewLine,
				e.EncodingName, e.CodePage, e.WindowsCodePage,
				e.WebName, e.HeaderName, e.BodyName,
				e.IsBrowserDisplay, e.IsBrowserSave,
				e.IsMailNewsDisplay, e.IsMailNewsSave);
		}
	}
}
以上代码执行结果为:
IBM EBCDIC (美国-加拿大)
        CodePage=37, WindowsCodePage=1252
        WebName=IBM037, HeaderName=IBM037, BodyName=IBM037
        IsBrowserDisplay=False, IsBrowserSave=False
        IsMailNewsDisplay=False, IsMailNewsSave=False

OEM 美国
        CodePage=437, WindowsCodePage=1252
        WebName=IBM437, HeaderName=IBM437, BodyName=IBM437
        IsBrowserDisplay=False, IsBrowserSave=False
        IsMailNewsDisplay=False, IsMailNewsSave=False

IBM EBCDIC (国际)
        CodePage=500, WindowsCodePage=1252
        WebName=IBM500, HeaderName=IBM500, BodyName=IBM500
        IsBrowserDisplay=False, IsBrowserSave=False
        IsMailNewsDisplay=False, IsMailNewsSave=False

阿拉伯字符(ASMO-708)
        CodePage=708, WindowsCodePage=1256
        WebName=ASMO-708, HeaderName=ASMO-708, BodyName=ASMO-708
        IsBrowserDisplay=True, IsBrowserSave=True
        IsMailNewsDisplay=False, IsMailNewsSave=False

阿拉伯字符(DOS)
        CodePage=720, WindowsCodePage=1256
        WebName=DOS-720, HeaderName=DOS-720, BodyName=DOS-720
        IsBrowserDisplay=True, IsBrowserSave=True
        IsMailNewsDisplay=False, IsMailNewsSave=False

希腊字符(DOS)
        CodePage=737, WindowsCodePage=1253
        WebName=ibm737, HeaderName=ibm737, BodyName=ibm737
        IsBrowserDisplay=False, IsBrowserSave=False
        IsMailNewsDisplay=False, IsMailNewsSave=False
		
省略......

Encoding的派生类提供的方法
方法名称 说明
GetPreamble 返回一个字节数组,指出在写入任何已编码字节之前,首先应该在一个流中写入什么字节。这些字节经常称为“前导码”(preamble)或“字节顺序标记”(Byte Order Mark, BOM)字节。开始从一个流中读取时,BOM 字节自动帮助检测当初写入流时采用的编码,以确保使用正确的解码器。对于从 Encoding 派生的一些类,这个方法返回0字节的数组——即没有前导码字节。显式构造 UTF8Encoding 对象,这个方法将返回一个 3 字节数组(包含 0xEF,0xBB0xBF)。显式构造 UnicodeEncoding 对象,这个方法将返回一个 2 字节数组(包含 0xFE 和 0xFF)来表示“高位优先”(big-endian)编码,或者返回一个 2 字节数组(包含 0xFF0xFE)来表示“低位优先”(litle-endian)编码。默认为低位优先
Convert 将字节数组从一种编码(来源编码)转换为另一种(目标编码)。在内部,这个静态方法调用来源编码对象的GetChars方法,并将结果传给目标编码对象的GetBytes方法。结果字节数组返回给调用者
Equals 如果从Encoding派生的两个对象代表相同的代码页和前导码设置,就返回true
GetHashCode 返回当前Encoding实例的哈希码

字符和字节流的编码(Encoder)和解码(Decoder)

假定现在要通过 System.Net.Sockets.NetworkStream 对象来读取一个 UTF-16 编码字符串。字节流通常以数据块(data chunk)的形式传输。换言之,可能是先从流中读取 5 个字节,再读取7个字节。UTF-16 的每个字符都由 2 个字节构成。所以,调用 Encoding 的 GetString方法并传递第一个 5 字节数组,返回的字符串只包含 2 个字符。再次调用 GetString 并传递接着的7个字节,将返回只包含3 个字符的字符串。显然,所有 code point 都会存储错误的值!

译注:code point是一个抽象概念。可将每个字符都想象成一个抽象的Unicode code point, 可能需要使用多个字节来表示一个code point。

之所以会造成数据损坏,是由于所有 Encoding 派生都不维护多个方法调用之间的状态。要编码或解码以数据块形式传输的字符/字节,必须进行一些额外的工作来维护方法调用之间的状态,从而防止丢失数据。

字节块解码首先要获取一个 Encoding 派生对象引用(参见上一节),再调用其 GetDecoder方法。方法返回对一个新构造对象的引用,该对象的类型从 System.Text.Decoder 类源生。

和 Encoding 类一样,Decoder 也是抽象基类。查阅文档,会发现找不到任何具体实现了Decoder 的类。但 FCL 确实定义了一系列 Decoder 派生类。这些类在 FCL 内部使用。但是,GetDecoder 方法能构造这些类的实例,并将这些实例返回给应用程序代码。

Decoder 的所有派生类都提供了两个重要的方法:GetChars 和 GetCharCount。显然,这些方法的作用是对字节数组进行解码,工作方式类似于前面讨论过的 Encoding 的 GetChars和 GetCharCount 方法。调用其中一个方法时,它会尽可能多地解码字节数组。假如字节数组包含的字节不足以完成一个字符,剩余的字节会保存到 Decoder 对象内部。下次调用其中一个方法时,Decoder 对象会利用之前剩余的字节再加上传给它的新字节数组。这样一来,就可以确保对数据块进行正确解码。从流中读取字节时Decoder 对象的作用很大。

从 Encoding 派生的类型可用于无状态(中途不保持状态)编码和解码。而从 Decoder 派生的类型只能用于解码。以成块的方式编码字符串需要调用 GetEncoder 方法,而不是调用Encoding 对象的 GetDecoder 方法。GetEncoder 返回一个新构造的对象,它的类型从抽象基类 System.Text.Encoder 派生。在文档中同样找不到谁具体实现了 Encoder。但 FCL 确实定义了一系列 Encoder 派生类。和从 Decoder 派生的类一样,这些类全都在 FCL内部使用,只是 GetEncoder 方法能构造这些类的实例,并将这些实例返回给应用程序代码。

从 Encoder 派生的所有类都提供了两个重要方法:GetBytes 和 GetByteCount。每次调用,从 Encoder 派生的对象都会维护余下数据的状态信息,以便以成块的方式对数据进行编码。

Base-64字符串编码和解码

示例——Base-64字符串编码和解码
// 获取一组10个随机生成的字节
Byte[] bytes = new Byte[10];
new Random().NextBytes(bytes);

// 显示字节
Console.WriteLine(BitConverter.ToString(bytes));

// 将字节解码成Base-64字节串,并显示字符串
String s = Convert.ToBase64String(bytes);
Console.WriteLine(s);

// 将Base-64字符串编码回字节,并显示字节
bytes = Convert.FromBase64String(s);
Console.WriteLine(BitConverter.ToString(bytes));
以上代码执行结果为:
46-1F-F7-2A-DF-2F-EB-AF-E0-F1
Rh/3Kt8v66/g8Q==
46-1F-F7-2A-DF-2F-EB-AF-E0-F1

安全字符串(SecureString)

String 对象可能包含敏感数据,比如用户密码或信用卡资料。遗憾的是,String 对象在内存中包含一个字符数组。如果允许执行不安全或者非托管的代码,这些代码就可以扫描进程的地址空间,找到包含敏感数据的字符串,并以非授权的方式加以利用。即使 String 对象只用一小段时间就进行垃圾回收,CLR 也可能无法立即重用 String 对象的内存,致使String的字符长时间保留在进程的内存中(尤其是假如 String 对象是较老的一代),造成机密数据泄露。此外,由于字符串不可变(immutable),所以当你处理它们时,旧的副本会逗留在内存中,最终造成多个不同版本的字符串散布在整个内存空间中。

CLR为了改善垃圾回收性能,引入了“代”(generation)的概念。简单地说,越新的对象,生命期越短。越老的对象,生命期越长。这些对象按照新老顺序,放入垃圾回收器专门分配的内存空间中(第0代,第1代和第2 代)。——译注

有的政府部门有严格的安全要求,对各种安全措施进行了非常具体的规定。为了满足这些要求,Microsoft 在 FCL 中增添了一个更安全的字符串类,即 System.Security.SecureString。构造 SecureString 对象时,会在内部分配一个非托管内存块,其中包含一个字符数组。使用非托管内存是为了避开垃圾回收器的“魔爪”。

这些字符串的字符是经过加密的,能防范任何恶意的非安全/非托管代码获取机密信息。利用以下任何一个方法,即可在安全字符串中附加、插入、删除或者设置一个字符:AppendChar,InsertAt, RemoveAtSetAt。调用其中任何一个方法时,方法内部会解密字符,执行指定的操作,再重新加密字符。这意味着字符有一小段时间处于未加密状态。还意味着这些操作的性能会比较一般。所以,应该尽可能少地执行这些操作。

SecureString 类实现了 IDisposable 接口,允许以简单的方式确定性地摧毁字符串中的安全内容。应用程序不再需要敏感的字符串内容时,只需调用 SecureStringDispose 方法。在内部,Dispose 会对内存缓冲区的内容进行清零,确保恶意代码无法获得敏感信息,然后释放缓冲区。SecureString 对象内部的一个字段引用了一个从 SafeBuffer 派生的对象,它负责维护实际的字符串。由于 SafeBuffer 类最终从 CriticalFinalizerObject 类派生(GC时保证了终结器方法一定会被执行),所以字符串在垃圾回收时,它的字符内容保证会被清零,而且缓冲区会得到释放。和 String 对象不同,SecureString对象在被回收之后,加密字符串的内容将不再存在于内存中。

知道了如何创建和修改 SecureString 对象之后,接着讨论如何使用它。遗憾的是,最新的FCL 限制了对 SecureString 类的支持。也就是说,只有少数方法才能接受 SecureString 参数。在.NET Framework 4 中,以下情况允许将 SecureString 作为密码传递。

● 与加密服务提供程序(Cryptographic Service Provider, CSP)协作。参见 System.Security.Cryptography.CspParameters 类。
● 创建、导入或导出 X.509 证书。参见 System.Security.Cryptography.X509Certificates.X509Certificate 类和 System.Security.Cryptography.X509Certificates.X509Certificate2 类。
● 在特定用户帐户下启动新进程。参见 System.Diagnostics.ProcessSystem.Diagnostics, ProcessStartInfo 类。
● 构造事件日志会话。参见 System.Diagnostics.Eventing.Reader.EventLogSession 类。
● 使用 System.Windows.Controls.PasswordBox 控件。参见该类的 SecurePassword 属性。

最后,可以创建自己的方法来接受 SecureString 对象参数。方法内部必须先让 SecureString对象创建一个非托管内存缓冲区,它将用于包含解密过的字符,然后才能让该方法使用缓冲区。为了最大程度降低恶意代码获取敏感数据的风险,你的代码在访问解密过的字符串时,时间应尽可能短。结束使用字符串之后,代码应尽快清零并释放缓冲区。此外,绝对不要将 SecureString 的内容放到一个 String 中。否则,String 会在堆中保持未加密状态,只有经过垃圾回收,而且内存被重用的时候,它的字符内容才会被清零。SecureString类特地没有重写 ToString 方法,目的就是避免泄露敏感数据。

下例演示了如何初始化和使用一个 SecureString(编译时要为 C#编译器指定/unsafe 开关选项):

111111.png


using System;
using System.Security;
using System.Runtime.InteropServices;

namespace ConsoleApp31
{
    class Program
    {
        static void Main(string[] args)
        {
            using (SecureString ss = new SecureString())
            {
                Console.Write("Please enter password: ");
                while (true)
                {
                    ConsoleKeyInfo cki = Console.ReadKey(true);
                    if (cki.Key == ConsoleKey.Enter) break;

                    // 将密码字符附加到SecureString中
                    ss.AppendChar(cki.KeyChar);
                    Console.Write("*");
                }
                Console.WriteLine();

                // 密码已输入,出于演示的目的显示它
                DisplaySecureString(ss);
            }
            // using之后,SecureString被dispose,内存中无敏感数据
            Console.ReadLine();
        }

        // 这个方法是不安全的,因为它要访问非托管内存
        private unsafe static void DisplaySecureString(SecureString ss)
        {
            Char* pc = null;
            try
            {
                // 将SecureString解密到一个非托管内存缓冲区中
                pc = (Char*)Marshal.SecureStringToCoTaskMemUnicode(ss);
                // 访问包含已解密SecureString的非托管内存缓冲区
                for (Int32 index = 0; pc[index] != 0; index++)
                    Console.Write(pc[index]);
            }
            finally
            {
                // 确定清零并释放包含已解密SecureString字符的非托管内存缓冲区
                if (pc != null)
                    Marshal.ZeroFreeCoTaskMemUnicode((IntPtr)pc);
            }
        }
    }
}


运行测试
222222.png

System.Runtime.InteropServices.Marshal 类提供了 5 个方法来将一个 SecureString 的字符解密到非托管内存缓冲区。所有方法都是静态方法,所有方法都接受一个 SecureString 参数,而且所有方法都返回一个 IntPtr。每个方法都另有一个配对的方法,必须调用配对方法来清零并释放内部缓冲区。下表总结了 System.Runtime.InteropServices.Marshal 类提供的将 SecureString 解密到内部缓冲区的方法以及对应的清零和释放缓冲区的方法。

Marshal类提供的用于操纵安全字符串的方法
将SecureString解密到缓冲的方法 清零并释放缓冲区的方法
SecureStringToBSTR ZeroFreeBSTR
SecureStringToCoTaskMemAnsi
ZeroFreeCoTaskMemAnsi
SecureStringToCoTaskMemUnicode
ZeroFreeCoTaskMemUnicode
SecureStringToGlobalAllocAnsi
ZeroFreeGlobalAllocAnsi
SecureStringToGlobalAllocUnicode ZeroFreeGlobalAllocUnicode

标签: C#

Powered by emlog  蜀ICP备18021003号-1   sitemap

川公网安备 51019002001593号