C#.Net筑基-类型系统②常见类型 类型系统 常见类型 C#. Net 筑基 C#
01、结构体类型Struct
结构体 struct 是一种用户自定义的值类型,常用于定义一些简单(轻量)的数据结构。对于一些局部使用的数据结构,优先使用结构体,效率要高很多。
- 可以有构造函数,也可以没有。因此初始化时可以
new
,也可以用默认default
。但当给字段设置了初始值时,则必须有显示的构造函数。 - 结构体中可以定义字段、属性、方法,不能使用终结器。
- 结构体可继承接口,并实现接口,但不能继承其他类、结构体。
- 结构体是值类型,被分配在栈上面,因此在参数传递时为值传递。
⁉️结构体始终都是分配在栈上吗?—— 不一定,当结构体是类的成员时,则会随对象一起分配在堆上。同时当结构体上有引用类型字段时,该字段只存储引用对象的地址,引用对象还是分配在堆上。
void Main()
{
Point p1 = default;
//Point p1 = default(Point);
Point p2 = new Point(1, 2);
p1.X = 100;
p2.X = 100;
}
public struct Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
}
1.1、只读结构体与只读函数
readonly struct
申明一个只读的结构体,其所有字段、属性都必须是只读的。
public readonly struct Point
{
public readonly int X,Y;
}
用在方法上,该方法中不可修改任何字段值。这只能用在结构体中,结构体不能继承,不知道这个特性有什么用?
public struct Point
{
public int X;
public int Y;
public readonly int GetValue()
{
X--; //Error:不可修改
return X + Y;
}
}
1.2、Ref 结构体
ref 结构类型 用ref struct
申明,该结构体只能存储在栈上,因此任何会导致其分配到堆上的行为都不支持,如装箱、拆箱,作为类的成员等都不支持。
Ref 结构体 可用于一些高性能场景,System.Span、ReadOnlySpan 都是 readonly ref struct
结构体。
public ref struct Point
{
public int X,Y;
}
02、枚举Enum
枚举类型 是由基础值类型(byte、int、long等)组成的一组命名常量的值类型,用enum
来申明定义。常用于一些有固定值的类别申明,如性别、方向、数据类型等。
- 枚举成员默认是
int
,可以修改为其他整数类型,如byte
、short
、uint
、long
等。 - 枚举项可设置值,也可省略,或者部分设置值。值默认是从
0
开始,并按顺序依次递增。 - 枚举变量的默认值始终是
0
。 - 枚举本质上就是命名常量,因此可以与值类型进行相互转换(强制转换)。
- 特性
Description
常用来定义枚项在UI上的显示内容,使用反射获取。
public enum UserType : int //常量类型,可以修改为其他整数类型
{
[Description("普通会员")]
Default,
VIP = 10,
SupperVIP, //继续前一个,值为11
}
void Main()
{
var t1 = UserType.Default;
Console.WriteLine(t1.ToString()); //输出名称:Default
Console.WriteLine((int)t1); //输出值:0
Console.WriteLine($"{t1:F}"); //输出名称:Default
Console.WriteLine($"{t1:D}"); //输出值:0
var t2 = (UserType)0;
int t3 = (int)UserType.Default;
Console.WriteLine(t1 == t2); //True
}
2.1、Enum 类API
System.Enum 类型是所有枚举类型的抽象基类,提供了一些API方法用于枚举的操作,基本都是静态方法。Enum 类型还可以作为泛型约束使用。
<TEnum>(TEnum)
获取枚举值的(常数)名称
GetNames<TEnum>()
获取枚举定义的所有(常数)名称数组
GetValues<TEnum>()
获取枚举定义的所有成员数组
IsDefined(Type, Object)
判断给定的值(数值或名称)是否在枚举中定义
Parse<TEnum>(String)
解析数值、名称为枚举,转换失败抛出异常
TryParse<TEnum>(String, TEnum)
安全的转换,同上,转换结果通过out参数输出,返回bool
表示是否转换成功
🔸其他
说明
Type.IsEnum
Type
的属性,用于判断一个类型是否枚举类型
2.2、位域Flags
枚举位域用[Flags]
特性标记,从而可以使用枚举的位操作,实现多个枚举值合并的的能力。在有些多选值的场景很有用,用一个数值可表示多个内容,如QQ的各种钻(绿钻、红钻、黄钻...)用一个值就可以表示,参考下面代码示例。
- 枚举定义时加上特性
[Flags]
。 - 要求枚举值必须是
2的n次方
,主要是各个成员的二进制值的对应位都不能一样,才能保障按位与、按位或运算的正确。 - 合并值用按位或
|
,判断是否包含可以用按位与&
,或者方法HasFlag(e)
。 - 枚举类型命名一般建议用复数名词。
void Main()
{
var t1 = QQDiamond.Green|QQDiamond.Red; //按位或运算,合并多个成员值
Console.WriteLine((int)t1); //3,同时为绿钻、红钻
//判断是否绿钻
Console.WriteLine(t1.HasFlag(QQDiamond.Green)); //True
//判断是否红钻,效果同上
Console.WriteLine((t1 & QQDiamond.Red) == QQDiamond.Red); //True
}
[Flags]
public enum QQDiamond : sbyte
{
None=0b0000, //或者0
[Description("绿钻")]
Green=0b0001, //或者1
Red=0b0010, //或者2、1<<1
Blue=0b0100, //或者4、1<<2
Yellow=0b1000,//或者8、1<<3
}
2.3、枚举值转换
枚举值为整形,枚举名称为string,因此常与int、string进行转换。
/Parse/TryParse方法解析
var t1 = Enum.Parse<QQDiamond>("3"); //Green
var t2 = Enum.Parse<QQDiamond>("Green"); //Green
//强转
QQDiamond t3 =(QQDiamond)56;
(int)TEnum
获取枚举值
字符格式:D(或X)
格式化中获取枚举值,D为十进制整形,X为16进制
//string
var s1 = qd.ToString(); //Green
var s2 = Enum.GetName(qd); //Green 不支持位于Flgas
var s3 = $"{qd:G}"; //Green
//int
var n1 = (int)qd; //1
var n2 = $"{qd:D}"; //1
03、日期和时间的故事
在System命名空间中有 下面几个表示日期时间的类型:都是不可变的、结构体(Struct)。
📢Ticks: 上面几个时间对象中都有一个
Ticks
值,其值为从公元0001/01/01开始的计数周期。1 Tick (一个周期)为100纳秒(ns),0.1微秒(us),千万分之一秒,可以看做是C#中的最小时间单位。
Console.WriteLine(DateTime.Now.Ticks); //638504277997063647
Console.WriteLine(DateTimeOffset.Now.Ticks); //638504277997063874
Console.WriteLine(TimeSpan.FromSeconds(1).Ticks); //10000000
3.1、什么是UTC、GMT?
UTC(Coordinated Universal Time)世界标准时间(协调时间时间),简单理解就是 0时区的时间,是国际通用时间。它与0度经线的平太阳时相差不超过1秒,接近格林尼治标准时间(GMT)。
格林尼治标准时间(Greenwich Mean Time,GMT)是指位于伦敦郊区的皇家格林尼治天文台的标准时间,因为本初子午线被定义在通过那里的经线。 理论上来说,格林尼治标准时间的正午是指当太阳横穿格林尼治子午线时的时间。
📢 由于地球在它的椭圆轨道里的运动速度不均匀,因此GMT是不稳定的。而UTC时间是由原子钟提供的,更为精确可靠,基本上已经取代GMT标准了。
我们日常使用的DateTime.Now
获取的时间其实是带了本地时区的(TimeZone),北京时区(+8小时),就是相比UTC时间,多了8个小时的偏差(时差)。DateTime 的Kind属性为DateTimeKind枚举,指定了时区类型:
- Unspecified:不确定的,大部分场景会被认为是
Local
的。 - Utc:UTC标准时区,偏移量为0。
- Local(默认值):本地时区的时间,偏移量根据本地时区计算,如北京时间的偏移量为
+8小时
。
public enum DateTimeKind
{
Unspecified,
Utc,
Local
}
3.2、DateTime
3.3、DateTimeOffset
DateTimeOffset 和 DataTime 很像,使用、构造方式、API都差不多。主要的区别就是多了时区(偏移Offset),构造函数中可以用 TimeSpan 指定偏移量。DateTimeOffset 内部有两个比较重要的字段:
- 用一个短整型
short _offsetMinutes
来存储时区偏移量(基于UTC),单位为分钟。 - 用一个DateTime 存储始终为UTC的日期时间。
DateTimeOffset.Now.Offset //08:00:00
UtcDateTime
返回本地UTC的DateTime
LocalDateTime
返回本地时区的DateTime
DateTime
返回Kind类型为Unspecified的DateTime,忽略了时区的DateTime值
用一个示例来理解DataTime、DataTimeOffset的区别: 比如你在一个跨国(跨时区)团队,你要发布一个通知:
- “本周五下午5点前提交周报”,不同时区都是周五下午5点前提交报告,虽然他们不是同一时刻,此时可用
DateTime
。- “明天下午5点开视频会”,此时则需要大家都在同一时刻上线远程会议,可能有些地方的是白天,有些则在黑夜,此时可用DateTimeOffset。
3.4、TimeSpan
TimeSpan 用来表示一段时间长度,最大值为1000W天,最小值为100纳秒。常用TimeSpan.From***()
、构造函数、或DateTime的 差值结果 来构造。
TimeSpan t1 =TimeSpan.FromSeconds(12); //00:00:12 //12秒
TimeSpan t2= new TimeSpan(12,0,0) - t1; //11:59:48 //11小时59分48秒
TimeSpan t3 = DateTime.Now.AddSeconds(12) - DateTime.Now; ////00:00:12
var t4 = new TimeSpan(15,1,0,0); //15.01:00:00 //15天1小时
var t5= DateTime.Now.TimeOfDay; //当天的时间
04、record是什么类型?
record 记录类型用来定义一个简单的、不可变(只读) 的数据结构,定义比较方便,常用于一些简单的数据传输场景。record 本质上就是定义一个class
类型(也可申明为record struct
结构体),因此语法上就是 类型申明+主构造函数的形式。
🚩 可以把 Record 看做是一个快速定义类(结构体)的语法糖,编译器会构建完整的类型。
- 构造函数中的参数会生成公共的只读属性,其他自动生成的内容还包括
Equals
、ToString
、解构赋值等。 - record 默认为
class
(可缺省),用record struct
则可申明为一个结构体的。 - record 类型可以继承另一个record类型,或接口,但不能继承其他普通
class
。 - 支持使用
with
语句创建非破坏性副本。
public record Car(string Width); //class
public record struct User(string Name, int Age);//struct
public record class Person(DateTime Birthday); //class
void Main()
{
var u1 = new User("sam",122);
var u2 = new User("sam",122);
u1.Age = 1; //只读,不可修改
Console.WriteLine(u1 ==u2); //True
Console.WriteLine(Object.ReferenceEquals(u1,u2)); //False
var (name,_) = u1; //解构赋值
Console.WriteLine(name); //sam
}
public record Person2 //创建一个可更改的recored类型
{
public string FirstName { get; set; }
public string LastName { get; set; }
};
通过查看编译后的代码来了解recored
的本质,下面是代码public record User(string Name, int Age)
编译后生成的代码(简化后),完整代码可查看在线 sharplab代码。
- 主构造函数中的参数都生成了只读属性,如果是
struct
结构体则属性是可读、可写的。 - 生成了
ToString()
方法,用stringBuilder 打印了所有字段名、字段值。 - 生成了相等比较的方法、相等运算符重载,及
GetHashCode()
,相等比较会比较字段值。 - 还生成了
Deconstruct
方法,用来支持解构赋值,var (name,age) = new User("sam",19);
。
public class User : IEquatable<User>
{
public string Name{get;init;}
public int Age{get;init;}
public User(string Name, int Age)
{
this.Name = Name;
this.Age = Age;
}
public override string ToString()
{
StringBuilder stringBuilder = new StringBuilder();
//把所有字段名、值输出
return stringBuilder.ToString();
}
public static bool operator !=(User left, User right)
{
return !(left == right);
}
public static bool operator ==(User left, User right)
{...}
public override int GetHashCode()
{...}
public virtual bool Equals(User other)
{...}
//支持解构赋值Deconstruct
public void Deconstruct(out string Name, out int Age)
{
Name = this.Name;
Age = this.Age;
}
}
record 申明可以用简化的语法(只有主构造函数,没有“身体”),也可以和class
一样自定义一些内部成员。如下面示例中,自定义实现了ToString
方法,则编译器就不会再生成该方法了,同时这里加了密封sealed
标记,子类也就不能重写了。
void Main()
{
var u = new User("John", 25);
Console.WriteLine(u.ToString());
u.SayHi();
}
public record User(string Name, int Age)
{
public sealed override string ToString() => $"{Name} {Age}";
public void SayHi() => Console.WriteLine($"Hi {Name}");
}
05、元祖Tuple
元祖 Tuple 其实就微软内置的一组包含若干个属性的泛型类型,包括结构体类型的 System.ValueTuple、引用类型的 System.Tuple,包含1到8个只读属性。
- System.ValueTuple,是值类型,结构体,成员是字段,可修改。
- System.Tuple 类型是引用类型,成员是只读属性。
📢 优先推荐使用 ValueTuple,这也是微软深度支持的,性能更好,默认类型推断用的都是ValueTuple。Tuple 作为历史的产物,在语言级别没有任何特殊支持。
下面代码为Tuple<T1>
的源代码,就是这么朴实无华,其他就是相等比较、ToString
、索引器。
public struct ValueTuple<T1, T2>
{
public T1 Item1;
public T2 Item2;
public ValueTuple(T1 item1, T2 item2)
{
Item1 = item1;
Item2 = item2;
}
}
🚩C#在语法层面对
ValueTuple
的操作提供了很多便捷支持,让元祖的使用非常简单、优雅,基本可以替代匿名类型。
- 简化
Tuplec
申明:用括号的简化语法,(Type,Type,...)
,(string,int)
等效于ValueTuple<string,int>
,编译器会进行类型推断。 - 值相等:元祖内部实现了相等比较操作符重载,比较的是字段值。
- 元素命名:元祖可以显示指定字段名称,比原来的无意义Item1、Item2好用多了。不过命名是开发态支持,编译后还是Item1、Item2,因此在运行时(反射)不可用。
- 解构赋值,元祖对解构的支持是编译器行为。
ValueTuple<double,double> p1 = new (1,5);
//简化语法
(double, double) p2 = (3, 5.5);
var p3 = (3, 5.5); //类型推断,进一步简化
var dis = p2.Item1 * p2.Item2; //Item1、Item2 成员
//值比较
Console.WriteLine(p2 == p3); //True
//命名,有名字的元祖
var p4 = (Name:"sam",Age:22);
Console.WriteLine(p4.Name); //sam
//解构赋值
var (n,age) = p4;
Console.WriteLine(n); //sam
元祖的一个比较适用场景就是方法返回多个值,虽然本质上还是一个“值”。
void Main()
{
var u = FindUser(1);
var (nn,ss) = FindUser(2);
Console.WriteLine(u.name+u.score);
Console.WriteLine(nn+ss);
}
public (string name,int score) FindUser(int id) //返回一个元祖
{
return ("sam",1000);
}
06、匿名类型(Class)
匿名类型就是无需事先申明,可直接创建任意实例的一种类型。使用 new {}
语法创建,创建时申明字段并赋值。
- 由编译器进行推断创建出一个完整类型。
- 匿名类型属性都是只读的,同时实现了相等比较、
ToString()
方法。
var u = new { Name = "same", Age = 10, Birthday = DateTime.Now };
Console.WriteLine(u.Name);
//u.Age=120; //只读不可修改
因此,匿名类型也是一种语法糖,由编译器来生成完整的类型。大多数场景都可以由 ValueTuple 代替,性能更好,也不需要额外的类型了。
07、其他内置类型
7.1、Console
Console 静态类,控制台输入、输出。
7.2、Environment
Environment 静态类,提供全局环境的一些参数和方法,算是比较常用了。
.NET 5
支持
CurrentManagedThreadId
当前托管现线程的ID
Is64BitOperatingSystem
获取操作系统是否64位,Is64BitProcess 获取当前进程是否64位进程。
NewLine
换行符(\\r\\n
)
OSVersion
获取操作系统信息
ProcessId
获取当前进程ID
ProcessorCount
获取CPU处理器核心数
UserName
获取当前操作系统的用户名
WorkingSet
获取当前进程的物理内存量
Exit(Int32)
退出进程
GetFolderPath(SpecialFolder)
获取系统特定文件夹目录,如临时目录、桌面等
SetEnvironmentVariable
设置环境变量
7.2、AppDomain、AppContext
- AppDomain 是.Net Framework时代的产物,用来表示一个应用程序域,进程中可以创建多个引用程序域,拥有独立的程序集、隔离环境。在.Net Core 中 其功能大大削弱了,不再支持创建AppDomain,就只有一个CurrentDomain了。
- AppContext 表示全局应用上下文对象,是一个静态类。.NET Core引入的新类,可用来存放一些全局的数据、开关,API比较少。
参考资料
- .NET类型系统①基础
- C# 文档
- 日期、时间和时区
- 《C#8.0 In a Nutshell》