获取对象的字符串表示 ToStirng

System.Object定义了一个pulic,virtual,无参的ToString方法.

System.Object的ToStirng实现的只是返回对象所属类型的全名,这对于许多不能提供有意义的字符串的类型来说,这是一个合理的默认值.

想要提供合理的方式获取对象当前值的字符串表示,就应重写ToString方法. 定义类时应该总是重写ToString方法,以提供良好的调试支持.

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

无参的ToString方法有两个问题:

  • 无法控制字符串的格式
  • 不能选择一种特定的语言文化来格式化字符串

为了解决这个,类型应该实现System.IFormattable接口:

// FCL的所有基类型都实现了这个接口, 另一些类型(Guid)也实现了它
public interface IFormattable
{
  String ToString(String format, IFormatProvider formatprovider);
}

IFormattable方法获取两个参数,

  • String format: 告诉方法如何格式化字符串
  • formatprovider: 是一个实现了IFormatProvider接口的实例类型,提供具体的语言文化信息.

FCL中定义的许多类型都能同时识别几种格式, 例如,

  • DateTime
    • d 表示短日期
    • D 表示长日期
    • g 表示常规
    • M 表示月/日
    • s 表示可排序
    • T 表示长时间
    • u 表示ISO8601格式的协调世界时
    • U 表示长日期格式的协调世界时
    • Y 表示年/月
  • 所有枚举
    • G 表示常规
    • F 表示标志
    • D 表示十进制
    • X 表示十六进制
  • 所有内建数值类型
    • C 表示货币格式
    • D 表示十进制格式
    • E 表示科学计数法
    • F 表示定点(fix-point)格式
    • G 表示常规格式
    • N 表示数字格式
    • P 表示百分比格式
    • R 表示往返行程(round-trip)格式
      • 保证转换为字符串的数值再次被分析为相同的数值
      • 此格式仅有浮点型(Single,Double)支持
    • X 表示十六进制
  • 数值类型还支持 picture数值格式字符串###,###可以显示千分位分隔符. (详情参考自定义数字格式字符串)

大多数类型,调用ToStirng传递null完全等价于传递格式字符串G.

关于语言文化,默认无参ToString方法默认使用与调用线程关联的语言文化信息进行格式化.如果formatprovider传null,IFormattable的ToString也这么做.

格式化数组(货币,整数,浮点数,百分比,日期和时间)适合应用对语言文化敏感的信息.

// 一下代码将以越南地区适用的货币格式来获取一个Decimal数值的字符串表示.
Decimal price = 123.54M;
String s = price.ToStirng("C",new CultureInfo("vi-VN"));
MessageBox.Show(s);

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

// 是ToString的真正实现
// 采用由调用者指定的格式和语言文化信息
public String ToString(String format, IFormatProvider formatprovider);

// 简单的调用ToString(null,formatprovider);
// 实现了IConvertible的ToString方法
public String ToString(IFormatProvider formatprovider);

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

String s = String.Format("On {0:D}, {1} is {2:E} years old.", new DateTime(2019, 8, 15), "AA", 9);
// 输出: On 2019年8月15日, AA is 9.000000E+000 years old.
Console.WriteLine(s);

在Format中,解析字符串时, 发现可替换参数0应该调用它的IFormattable接口的ToString方法,并为方法传递”D”,null参数.

但是如果使用StringBulider而不是String来构造字符串,可以调用StringBulider的AppendFormat方法.AppendFormat原理与String的Format相似.

Console的Write/WriteLine要格式化符合特定语言文化的字符串必须要调用String的Format方法.

提供定制格式化器

可以定义一个方法,在任何对象需要格式化字符串的时候由StringBuilderAppendFormat方法调用该方法,按照我们希望的任何方式格式化对象. (也适用于String的Format方法)

也就是说,AppendFormat不是为每个对象调用ToString方法,而是调用自定义的方法.

以下代码为了将所有的Int32值在HTML中加粗显示.

/// <summary>
/// 格式化定制器
/// 将所有的Int32值在HTML中加粗显示.用<B></B>加粗显示
/// </summary>
namespace FormatController
{
    class Program
    {
        public static void Main(string[] args)
        {
            StringBuilder sb = new StringBuilder();
            // 传入定制类BoldInt32s为参数
            sb.AppendFormat(new BoldInt32s(), "{0} {1} {2:M}", "AAA", 23, DateTime.Now);
            //输出 AAA <b>23</b> 8月15日
            Console.WriteLine(sb);         
            Console.ReadKey(true);
        }
    }
    // 需要实现IFormatProvider和ICustomFormatter接口
    internal sealed class BoldInt32s : IFormatProvider, ICustomFormatter
    {
        public object GetFormat(Type formatType)
        {
            if(formatType == typeof(ICustomFormatter))
            {
                return this;
            }       
            return Thread.CurrentThread.CurrentCulture.GetFormat(formatType);
        }       
        public string Format(string format, object arg, IFormatProvider formatProvider)
        {
            string s;       
            IFormattable formattable = arg as IFormattable;         
            if(formattable == null)
            {
                s = arg.ToString();
            }
            else
            {
                s = formattable.ToString(format, formatProvider);
            }         
            if(arg.GetType() == typeof(Int32))
            {
                return "<b>" + s + "</b>";
            }       
            return s;
        }     
    }         
}

AppendFormat的工作方式:

  1. 需要格式化一个可替换参数时,会调用ICustomFormatter的Format方法
  2. 如果不支持就调用简单的无参的ToString方法
  3. 如果对象支持IFormattable就调用支持富格式化的ToString,向它传递字符串和格式提供器.
  4. 最后核实类型是否是需求的类型,处理自定义操作.

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

从某种意义上来说,Parse扮演了一个工厂的角色. 能解析字符串的任何类型都提供了公共静态方法Parse.

以Int32类型的Parse方法为例(其他数值类型的Parse方法与此相似):


public static Int32 Parse(String s,NumberStyles style, IFormatProvider provider);
  • NumberStyles 参数是标志集合,标识了Parse应在字符串查找的字符,也就是s中允许的样式,不允许的会抛出异常.(也就是说 string中不能出现不对应类型的字符)
  • IFormatProvider 获取语言文化特有的信息
    ```csharp
    // 会抛出FormatException
    // 字符串包含了前导空白符
    // Int32 x = Int32.Parse(“ 123”,NumberStyles.None, null);

// 这样修改,能跳过前导空白符
Int32 x = Int32.Parse(“ 123”,NumberStyles.AllowLeadingWhite, null);

// 解析16进制数
Int32 x = Int32.Parse(“1A”,NumberStyles.HexNumber, null); // x : 26

// 上述方法需要传递3个参数,为了简化编程,还提供了Parse方法的4个重载版本.


[数字NumberStyles的详细文档.](https://docs.microsoft.com/zh-cn/dotnet/api/system.globalization.numberstyles?redirectedfrom=MSDN&view=netframework-4.8)

[日期的DateTimeStyles的详细文档](https://docs.microsoft.com/zh-cn/dotnet/api/system.globalization.datetimestyles?redirectedfrom=MSDN&view=netframework-4.8)

对日期和事件的解析比较复杂, DateTime类型的Parse方法过于宽松,还提供`ParseExact`方法,接收一个picture参数,能准确描述应该如何格式化日期/时间字符串,以及如何解析.

[参考DateTimeFormatInfo的详细文档](https://docs.microsoft.com/zh-cn/dotnet/api/system.globalization.datetimeformatinfo.-ctor?redirectedfrom=MSDN&view=netframework-4.7.2)

## 关于Parse性能上的问题

如果应用程序频繁调用Parse,而且Parse频繁抛出异常(用户无效输入),应用程序性能会显著下降.

为此, Microsoft为所有的`数值数据类型`,`DateTime类型`,`TimeSpan类型`,`IPAddress类型`中加入`TryParse`方法.

```csharp
// 方法返回bool, 指出传递的字符串是否能解析成功,以传引用的方式传给result
public static bool TryParse(string s,NumberStyles style,IFormatProvider provider,
  out int result);

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

System.IO.BinaryWriter或者System.IO.StreamWriter类型将字符串发送给文件或网络流时,通常要进行编码。对应地,System.IO.BinaryReader或者System.IO.StreamReader类型从文件或网络流中读取字符串时,通常要进行解码。不显式指定一种编码方案,所有这些类型都默认使用UTF-8。

FCL提供了一些类型来简化字符编码和解码. 两种最常用的编码方案是UTF-16和UTF-8.

  • UTF-16: 将每个16位字符编码成2个字节。不会对字符产生任何影响,也不发生压缩—性能非常出色。UTF-16编码也称为Unicode编码。(可以从低位优先转换成高位优先,或者高位优先转换成低位优先)

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

  • ASCII: 编码方案将16位字符编码从ASCII字符:也就是说,值小于0x0080的16位字符被转换成单字节。值超过0x007F的任何字符都不能被转换,否则字符的值会丢失。

应该总是选择UTF-16 UTF-8编码

String s = "Hi there.";

// 它知道怎么使用UTF-8来进行编码和解码
Encoding encodingUTF8 = Encoding.UTF8;

// 将字符串编码成字节数组
Byte[] encodingBytes = encodingUTF8.GetBytes(s);

// 显式编译好的字节值
// 输出:48-69-20-74-68-65-72-65-2E
Console.WriteLine(BitConverter.ToString(encodingBytes));

// 将字节数组解码回字符串
String decodeString = encodingUTF8.GetString(encodingBytes);

// 显式解码的字符串
// 输出: Hi there.
Console.WriteLine(decodeString);

不建议使用Encoding类的静态属性Defult,它返回的是对象使用用户当前的代码页来进行编码和解码(在用户控制面板的区域和语言选项中指定),也就有是说应用程序的行为会随着机器的设置而变.

UnicodeEncoding,UTF8Encoding,UTF32Encoding,UTF7Encoding提供了多个构造器, 允许对编码和前导码进行更多的控制. 前3个类还提供了特殊的构造器,允许在对一个无效的字节序列进行解码时抛出异常,应该使用这些能抛出异常的类.

前导码: 也称字节顺序标记(BOM,Byte Order Mark).

字符和字节流的编码和解码

字节流通常以数据块data chunk的形式传输, 可能先从流中读5个字节,然后再7个字节,因为UTF-16每个字符都是2个字节,那么调用Encoding的GetString方法传递一个5字节的数组返回的字符串只包含2个字符,后面的7个字节返回3个,显然会造成数据损坏.

字节块解码首先要获取一个Encoding对象,在调用其GetDecoder方法.

  • 返回一个新构造对象的引用,从抽象类System.Text.Decoder派生.
  • Decoder提供了2个重要方法
    • GetChars和GetCharCount.
    • 它会尽可能多的解码字节数组. 如果剩余一个字节,
    • 则会保存到Decoder对象内部,下次调用时取出,和新的字节数组合并.
    • 这样就能正确解码.

从流中读取字节时,Decoder对象的作用很大.

每次调用从Encoder派生的对象都会维护余下数据的状态信息,以便成块的方式对数据进行编码.

Base-64字符串编码和解码

除了UTF-16和UTF-8,另一个流行方案是将字节序列编码成Base-64字符串.

例外的是,Base-64编码和解码不是用Encoding派生类型来完成,而是用Convert静态方法ToBase64String或ToBase64CharArray方法.

// 获取10个随机生成的字节
Byte[] bytes = new Byte[10];
new Random().NextBytes(bytes);

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

// 将Base-64字符串编码回字节,并显示
bytes = Convert.FromBase64String(s);
// 错误用法, 显示的是类名 System.Byte[]
// Console.WriteLine(bytes.ToString());
// 输出: BA-C2-D5-75-59-48-80-08-1F-B3
Console.WriteLine(BitConverter.ToString(bytes));

安全字符串

String对象可能包含敏感数据,比如信用卡资料,密码. String对象会在内存中包含一个字符数组. 执行不安全代码或者非托管代码就可以扫描进程的地址空间,找到包含敏感数据的字符串.

即使String对象只用一小段时间就进行垃圾回收, CLR也可能无法立即重用String对象的内存, 致使String的字符长时间保留在进程的内存中. 由于字符串不可变,处理它们的时候,旧的副本会逗留在内存中,最终造成多个不同版本的字符串散布在整个内存空间中.

FCL增加了一个更安全的字符串类:System.Security.SecureString

构造SecureString对象时,会在内部分配一个非托管内存块,其中包含一个字符数组.

  • 使用非托管内存块是为了避开垃圾回收器的魔爪.
  • 这些字符串是经过加密的,能防范任何恶意的非安全/非托管代码获取机密信息.

SecureString类的操作方法:

  1. AppendChar 附加
  2. InsertAt 插入
  3. RemoveAt 删除
  4. SetAt 设置一个字符

但是调用这些方法时,方法内部会进行 解密 ,执行完指定的操作再 重新加密 字符串.这说明有一段时间是处于未加密的状态. 并且这些操作性能会比较一般.

SecureString类实现了IDisposable接口. 用简单的方式 确定性 地摧毁字符串中的安全内容. 只需要调用SecureStringDispose方法. 在方法内部,Dispose会对内存缓冲区的内容进行清零.然后释放缓冲区.

SecureString对象内部有一个SafeBuffer派生的对象负责维护字符串,SafeBuffer类最终从CriticalFinalizerObject类派生,所以在垃圾回收时,内容保证清零,缓冲区被释放.

SecureString对象被垃圾回收后,加密的字符串的内容将不再存于内存中.

FCL限制了对SecureString类的支持. 只有少数方法才能接受SecureString参数.

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

public static void Main(string[] args)
{
    // using代码块之后,SecureString被dispose,内存中无敏感数据
    using (SecureString ss = new SecureString())
    {
        Console.WriteLine("请输入密码:");
        while (true)
        {
            // 参数true意思是,拦截输入,不显示在控制台上
            ConsoleKeyInfo cki = Console.ReadKey(true);
            // 回车表示输入完成
            if (cki.Key == ConsoleKey.Enter) break;

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

        Console.WriteLine();
        // 密码已经输入,出于演示的目的显示它
        DisplaySecureString(ss);
    }
}

// 这个代码是unsafe,要访问非托管内存
private unsafe static void DisplaySecureString(SecureString ss)
{
    Char* pc = null;
    try
    {
        // 将SecureString解密到一个非托管内存缓冲区
        pc = (char*) Marshal.SecureStringToCoTaskMemUnicode(ss);

        // 访问包含已经解密SecureString字符的非托管内存缓冲区
        for (int i = 0; pc[i] != 0; i++)
        {
            Console.Write(pc[i]);
        }
    }
    finally
    {
        // 确定清零并释放包含已解锁SecureString字符的非托管内存缓冲区
        if (pc != null)
        {
            Marshal.ZeroFreeCoTaskMemUnicode((IntPtr) pc);
        }
    }
}

什么情况下用SecureString?

如果以下情况下,SecureString非常有用:

  • 您可以逐字符构建它(例如从控制台输入),或者从非托管API获得它。
  • 您可以通过将它传递给非托管API(SecureStringToBSTR)来使用它。
    如果您曾经将其转换为托管字符串,则您已经放弃了它的用途。

我会看到的主要用例是在客户端应用程序中,它要求用户输入高度敏感的代码或密码。用户输入可以逐字符用于构建SecureString,然后将其传递给非托管API,该API对它使用后接收的BSTR进行零。任何后续内存转储都不会包含敏感字符串。
在服务器应用程序中,很难看出它在哪里有用。

Marshal类提供用于操纵安全字符串的方法

System.Runtime.InteropServices.Marshal类提供5个方法来将一个SecureString的字符 解密到非托管 内存缓冲区. 所有方法都是静态方法.所有方法都接受一个SecureString参数,并返回IntPtr.

每个方法都有一个配对方法来清零并释放内部缓冲区.