- 介绍
- 具体案例
- 限制泛型参数只能使用值类型
- 泛型参数的输入和输出
- 将抽象类作为类型约束
- 使用Span提升处理字符串的性能
- 多个Task同时操作ConcurrenBag集合
- 跨线程访问BlockingCollection集合
- 总结
随着.net core
越来越流行,对.net core
基础知识的了解,实际应用等相关的知识也应该有所了解。所以就有了这篇文章,案例都是来自阅读的书籍,或者实际工作中感觉比较有用的应用。分享亦总结。
本文主要介绍 .net core
相关的泛型和集合案例。
【导语】
因为泛型参数可以是任意类型,所以如果编译器仅仅将参数假定位 Object
类型(即按照 Object
类的成员进行访问),那么代码在访问某些特定类型时就会引发编译器错误。
为了避免引发编译错误,有时候需要对泛型参数进行限制,也就是泛型约束。常用的泛型约束如下。
(1)class:限制泛型参数的类型必须是类(引用类型)。
(2)struct:限制泛型参数的类型必须是结构(值类型)。
(3)new():要求类型必须包含公共的、无参数的构造函数。当使用多个约束时,new() 必须放到最后。
(4)unmanaged:要求类型是非托管类型。此于数不常用。
(5)接口或者基类:要求类型必须实现指定接口,或者必须从指定类型派生。
泛型约束可以组合使用,例如下列泛型类要求类型参数是引用类型(class
),而且要求实现 IService
接口。
public class `Main` Item where U:calss,IService
{
...
}
泛型约束的使用方法是在声明类型或类型成员之后应用 where
关键字,然后才是类型参数的约束条件。如果由多个泛型参数,也可以用多个 where
关键字,格式如下。
public class SomeOne
where T: class, new()
where S: ITask
{
}
【操作流程】
步骤1:新建控制台应用程序项目。
步骤2:声明一个类,命名位 Test
,带由一个泛型参数 T。
class Test
{
...
}
步骤3:对泛型参数 T 进行约束,限制只能是值类型,即类型为结构。
class Test where T : struct
{
...
}
步骤4:在类中声明一个 Start
方法,并接受一个 T 类型参数。
public void Start(T x)
{
string CheckType(Type t) => t.IsValueType ? "是" : "不是";
Type type = x.GetType();
Console.WriteLine($"{type.Name} {CheckType(type)}值类型。");
}
CheckType
是一个内联方法,它的格式与方法类似,但是不需要指定方法修饰符,一般用于实现简单的逻辑处理。如本例中的 CheckType
,只是用于判断类型 T 是否为值类型,如果是返回字符串“是”,否则返回字符串“不是”。
步骤5:回到 Main
方法,用定义好的泛型类声明一个变量,然后创建一个新实例。随后调用 Start
方法,此时类型参数 T 为 int
类型。
Test tv = new Test();
tv.Start(100);
步骤6:类似的,在声明一个 Test
类的变量实例化,然后调用 Start
方法,此时类型参数 T 为 byte
类型。
Test tq = new Test();
tq.Start(152);
步骤7:运行应用程序项目,结果如下。
【导语】
如果泛型参数不带任何修饰符,那么在分配对象实例时,类型参数只能是固定的类型。例如以下形式。
Class x = new Class();
假设 B 类从 A 类派生,则分配以下对象实例时会报错。
Class x = new Class();
因为泛型参数为使用任何修饰符,使用参数类型是固定的。声明变量时使用的是 A 类型,而分配对象实例时使用的是 B 类型,前后不一致,会出现编译错误。
要使泛型中的类型参数成为变体,一般可以使用两个修饰符————in
和 out
。带 in
修饰符的是“输入类型”,此类型参数一般用于委托或方法的输入参数,属于逆变。带 out
修饰符的是“输出参数”,一般用于委托或方法的返回值,属于协变。
泛型的输入/输出类型参数只能用于委托和接口两种数据类型,不能用于类和结构,而且作为类型参数的类型不能是值类型。
【操作流程】
步骤1:新建控制台应用程序项目。
步骤2:声明两个类,稍后用于测试
public class Ball { }
public class FootBall : Ball { }
FootBall
类从 Ball
类派生。按照隐式转换的要求,FootBall
类的实例可以赋值给使用 Ball
类型声明的变量。
步骤3:声明两个带可变类型参数的泛型接口。
public interface ITest1 { }
public interface ITest2 { }
在 ITest1
接口中,T 类型参数使用了 in
修饰符,表示它是一个输入类型,即逆变。在 ITest2
接口中,T 类型参数使用 out
修饰符,表示该类型将作为输出擦拭你和(返回值),即协变。
步骤4:声明两个类,分别实现 ITest1
和 ITest2
接口。
public class Test1 : ITest1 { }
public class Test2 : ITest2 { }
步骤5:在 Main
方法中用 ITest1
接口声明变量,类型参数为 FootBall
类,然后用 Test1
类的新实例进行赋值,类型参数为 Ball
类。
ITest1 t1 = new Test1();
上述情况属于逆变。t1 变量的泛型参数只能接受 FootBall
类型,而它所引用的实例的泛型参数则可以接受 Ball
和 FootBall
两个类型,可以看到,赋值之后实例能分配的兼容性变小了,当通过 t1 变量调用相关成员时,只能使用 FootBall
类。
步骤6:用 ITest2
接口声明变量,并指定泛型参数为 Ball
类,使用 Test2
实例赋值的泛型参数为 FootBall
类。
ITest2 t2 = new Test2();
变量 t2 能接收 Ball
和 FootBall
两个类型的返回值,而它所引用的实例只能返回 FootBall
类型的对象。当使用 t2 变量调用相关成员时,由于ITest2
的分配兼容性变大,能够顺利引用Test2
实例所返回的对象,此情况属于协变。
【导语】
将泛型参数约束为抽象类或接口,可以有效规范对类型实例的访问,中在实际开发中比较实用。如果不给类型参数添加约束,那么在默认情况下编译器就会以 Object
类的成员进行规范,这个会带来诸多不便。
然而如果是应用抽象类或者接口来约束类型参数,那么在访问参数实例时就很方便了。例如声明一个接口,名为 IService
,接口中声明两个 方法————Open
和 Close
。在泛型参数中添加约束,要求类型必须实现 IService
接口。在这种情况下,访问泛型参数实例的代码不需要关心有多少个类实现了 IService
接口,因为不管是哪个类,只要它实现了该接口,必然会包含 Open
和 Close
这两个方法,如此一来,代码只需要带调用方法。
【操作流程】
步骤1:新建控制台应用程序项目。
步骤2:为了便于稍后测试,先声明一个抽象类 Animal
,它表示所有动物,同时包含一个抽象的 CheckIn
方法。
///
/// 公共基类
///
public abstract class Animal
{
public abstract void CheckIn();
}
所有从 Animal
类派生的类都必须实现 CheckIn
方法。
步骤3:声明 4 个新类,都实现 Animal
抽象类。
///
/// 猫鼬
///
public class Meerkat : Animal
{
public override void CheckIn()
{
Console.WriteLine("这是猫鼬。\n");
}
}
///
/// 狐狸
///
public class Fox : Animal
{
public override void CheckIn()
{
Console.WriteLine("这是狐狸。\n");
}
}
///
/// 鸡
///
public class Chicken : Animal
{
public override void CheckIn()
{
Console.WriteLine("这是鸡。\n");
}
}
///
/// 鹌鹑
///
public class Quail : Animal
{
public override void CheckIn()
{
Console.WriteLine("这是鹌鹑。\n");
}
}
步骤4:声明一个泛型接口,将类型参数标注为输入参数,这样可以在后续使用中支持传递不同派生程度的类。
public interface ITest
{
void DoWork(T pr);
}
步骤5:声明泛型类,并是实现上面的泛型接口,将类型参数约束为必须是从 Animal
派生的类。
public class TestAnl : ITest
where T : Animal
{
public void DoWork(T pr)
{
// CheckIn 方法是在抽象类中定义的
// 所有实现该抽象类的类型都能访问
pr.CheckIn();
}
}
步骤6:使用 ITest
接口声明一个变量,然后使用 TestAnl
类的新实例对其初始化,类型参数使用 Animal
类,这样做能够增加调用 DoWork
方法的灵活性,能够向该方法传递各种 Animal
类的子类实例。
ITest t = new TestAnl();
步骤7:调用 DoWork
方法,依次将 Animal
类的 4 个派生类实例传递进去。
t.DoWork(new Fox());
t.DoWork(new Meerkat());
t.DoWork(new Quail());
t.DoWork(new Chicken());
步骤8:运行应用程序项目,结果如下。
【导语】
.NET
中许多类型都是在托管堆中分配内存的,在对连续内存卡进行操作时,某些数据类型会比较花时间,其中最典型的是字符串。在处理字符串的过程中会不断创建新的实例,这些过程必将占用一定的时间。
.NET Core
类库提供了一种特殊的结构————Span
,它可以用于操作各种连续分布的内存数据。可以通过以下来源初始化 Span
。
- 常见的托管数组。
- 栈内存中的对象。
- 本地内存指针。
此外,Span
支持 GC 功能,不需要显示释放内存。与 Span
对应,还有一个只读版本————ReadOnlySpan
。
本实例演示了用两种方法将某个字符串中的两个字符(“2”和“0”)转换为 int
数值 20,并且使用 Stopwatch
组件分布计算两种方法所消耗的时间。
【操作流程】
步骤1:新建控制台应用程序项目。
步骤2:声明并初始化一个字符串实例,稍后测试使用。
string str = "我家里养了20只猫";
步骤3:第一种处理方法,调用 Substring
方法取出“2”和“0”两个字符串,在通过 Parse
方法产生 int
实例。
Stopwatch sw1 = Stopwatch.StartNew();
for(int x = 0; x < 10000000; x++)
{
int v = int.Parse(str.Substring(5, 2));
}
sw1.Stop();
Console.WriteLine($"常规方法:耗时 {sw1.ElapsedMilliseconds} ms");
步骤4:第二种方法,使用 Span
,T 为 char
类型。调用 Slice
方法取出“2”和“0”两个字符串,在通过自定义代码将其转换为 int
值。
Stopwatch sw2 = Stopwatch.StartNew();
ReadOnlySpan span = str.ToCharArray();
for (int x = 0; x < 10000000; x++)
{
int v = 0;
var subspan = span.Slice(5, 2);
for(int i = 0; i < subspan.Length; i++)
{
char ch = subspan[i];
v = (ch - '0') + v * 10;
}
}
sw2.Stop();
Console.WriteLine($"使用 Span:耗时 {sw2.ElapsedMilliseconds} ms");
由于 Span
实例中取出来的元素是 char
类型,为了能得到与字符串相对应的整数值,应该将其减去字符“0”的ASCII码。字符“0”的 ASCII
码是 48,以此类推,字符“1”的 ASCII
码 49,字符“2”的 ASCII
码 50,如果要使字符“2”与整数 2 对应,就需要 50 减去 48。
步骤5:运行应用程序项目,结果如下。
很明显采用Span
的效率比较高。
注意:为了能让对比的效果更直观,本实例在要给 for
循环中将每种给处理方法的代码重复执行了 10000000
次。 在计时的时候,Stopwatch
组件自身也会消耗一定的 CPU
资源,因此代码执行所耗费的准确时间与 Stopwatch
所计算结果会有误差,仅用于参考。
【导语】
ConcurrentBag
是一个泛型集合,它比较明显的优点是可以在多个线程上同时访问。并且该集合是无序的,即从集合中取出元素的顺序可能与放入的顺序不一样。
要向集合中添加元素可以调用 Add
方法。而取出元素则有两个方法可用:TryTake
方法取出元素然后把元素从集中删除,TryPeek
方法取出元素但是不会删除元素。
要判断 ConcurrentBag
集合中是否存在元素,一种方法是访问 Count
属性,它表示集合中包含元素的个数;另一种方法是访问 IsEmpty
属性,如果集合为空则返回 true
。
【操作流程】
步骤1:新建控制台应用程序项目。
步骤2:实例化 ConcurrentBag
集合,参数 T 为 int
类型。
ConcurrentBag bag = new ConcurrentBag();
步骤3:在项目生成的 Program.cs
文件头部的 using
指令块中,添加对以下命名空间的引用。
using System.Collections.Concurrent;
using System.Threading.Tasks;
步骤4:启动第一个 Task
,向集合中添加元素。
Task t1 = Task.Run(() =>
{
for (int k = 45; k < 51; k++)
{
Console.WriteLine("即将添加元素:{0}", k);
bag.Add(k);
}
});
步骤5:启动第二个 Task
,从集合中取出元素,此 Task
在第一个 Task
执行之后执行。
Task t2 = t1.ContinueWith((task) =>
{
while (!bag.IsEmpty)
{
if(bag.TryTake(out int item))
{
Console.WriteLine("已取出元素:{0}", item);
}
}
});
ContinueWith
方法指定在某个 Task
之后延续新的 Task
。如果两个 Task
同时执行,由于集合的初始化状态是空的,会导致第二个 Task
中无法获取到元素。所以要让第一个 Task
先执行,这样在执行第二个 Task
时,就能保证集合中是有元素的。
步骤6:调用 WaitAll
方法,让当前线程暂定执行,等待上面两个 Task
任务执行完成再继续。
Task.WaitAll(t1, t2);
步骤7:运行应用程序项目,结果如下。
【导语】
BlockingCollection
集合允许多线程访问(添加或移除元素)。当集合中的元素数量达到容量上限时,添加操作会被阻止,直到集合中有元素被移除(重新获取到可用容量);同样的,当从集合中移除元素时,如果集合中无可用元素,那么移除操作会别阻止,直到集合中有新的元素加入。
多个线程可用同时调用 Add
或 TryAdd
方法向集合中添加元素,也可以同时调用 Take
或 TryTake
方法移除元素。当调用 CompleteAdding
方法后,就不能在向集合中添加元素了,否则会引发异常。
【操作流程】
步骤1:新建控制台应用程序项目。
步骤2:创建 BlockingCollection
实例,T 为 int
类型。
using (BlockingCollection bc = new BlockingCollection())
{
...
}
BlockingCollection
类实现了 IDisposable
接口,若不再使用要将其释放。将初始化代码放在 using
语句块中,当代码离 using
块时会自动调用 Dispose
方法。
步骤3:在 using
代码块内部,创建第一个 Task
,用于向集合中添加元素。
Task t1 = Task.Run(async () =>
{
for (int k = 0; k < 5; k++)
{
int item = k + 1;
Console.WriteLine("即将添加元素:{0}", item);
bc.Add(item);
await Task.Delay(650);
}
// 标记添加操作已完成
bc.CompleteAdding();
});
在调用 CompleteAdding
方法后,集合被标志位已完成添加操作,此时所有线程均无法在向集合中添加元素。
步骤4:创建第二个 Task
,用于从集合中移除元素。
Task t2 = Task.Run(() =>
{
while (true)
{
if (bc.TryTake(out int item))
{
Console.WriteLine("取出元素:{0}", item);
}
// 是否退出循环
if (bc.IsCompleted) break;
}
});
当集合中的所有元素都被移除了(空集合),IsCompleted
属性会变为 true
,此时就可以退出 while
循环了。
步骤5:调用 WaitAll
方法等待所有 Task
执行完成,然后释放上述两个 Task
。
Task.WaitAll(t1, t2);
t1.Dispose();
t2.Dispose();
步骤6:运行应用程序项目,结果如下。
本文到这里就结束了,下一篇将介绍LINQ 的知识案例。