文章

C#out、in关键字在参数修饰和泛型使用的区分.

区分C#中out、in关键字作为参数修饰符和泛型变体修饰符时的不同含义...

C#out、in关键字在参数修饰和泛型使用的区分.

C#out、in关键字在参数修饰和泛型使用的区分

in、out在C#中有两种完全不同的使用场景:

  • 作为方法参数修饰符,表示参数如何传递
  • 作为泛型类型参数修饰符,表示泛型类型之间是否支持变体转换

参数修饰

 修饰符调用方是否需要显示声明调用参数方式
1无否数据副本
2in否引用(只读)
3ref是引用(可读写)
4out是引用(输出)

无参数

  • 值类型

    当修饰的参数为值类型时:会传入一个复制出来的副本,方法内部修改这个副本不会影响原变量

  • 引用类型

    当修饰的参数为引用类型时,会传入当前参数的引用副本,与原本参数指向相同对象

1
2
3
4
5
6
7
8
static void Change(string text)
{
    text = "world";
}

string name = "hello";
Change(name);
Console.WriteLine(name);//hello,变量name还是指向内存中的"hello"
1
2
3
4
5
6
7
8
9
10
11
12
13
class People
{
    public string name = "hello";
}

static void ChangePeopleName(People people)
{
    people.name = "world";//可以修改对象内容
}

People people = new();
ChangePeopleName(people);
Console.WriteLine(people.name);//world

in

  • 传入变量存储位置的引用(只读)
  • 避免复制较大的结构体
  • 不允许对参数本身进行重新赋值
  • 调用方可以省略in,省略时编译器可能创建临时变量来传递参数

in参数本身不能被重新赋值,但是当参数是引用类型时,仍然可以修改它指向的对象内容。in只限制引用本身,不限制引用对象的内部状态。

1
2
3
4
5
6
7
8
static void Change(in string text)
{
    //text = "world";//非法,不允许对参数本身进行重新赋值
}

string name = "hello";
Change(name);//合法,调用方可以省略in
Change(in name);//也合法,显式写出in
  • 结构体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public struct People
{
    public string name;
    public int money;

    public void MoneyAdd() => money++;

    public People(string name, int money)
    {
        this.name = name;
        this.money = money;
    }
}

static void ChangePeopleName(in People people)
{
    //people = new("name", 1);//非法,不允许对参数本身进行重新赋值
    //people.name = "world";//非法,不能修改只读结构体参数的字段
}

static void AddMoney(in People people)
{
    people.MoneyAdd();//可变结构体通过in参数调用实例方法时,会触发防御性复制
                      //因此这里修改的是副本,不会修改原值
}

People people = new("name", 1);
ChangePeopleName(in people);//合法,in参数允许显式写出in

AddMoney(people);
Console.WriteLine(people.money);//1,原结构体没有被修改

当in参数是可变结构体时,调用它的非只读实例方法可能会产生防御性复制。若希望结构体能够安全地通过只读引用使用,可以将结构体声明为readonly struct,并将需要的成员声明为只读成员。

  • 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Man
{
    public string name = "hello";
}

static void ChangeManName(in Man man)
{
    //man = new();//非法,不允许对参数本身进行重新赋值
    man.name = "world";//可以修改对象内容
}

Man man = new();
ChangeManName(man);
Console.WriteLine(man.name);//world

对于引用类型,in通常不能带来和大结构体一样的复制优化,因为传递的本来就是一个引用副本。这里主要体现的是不能替换参数引用本身,但可以修改对象内容。

ref

  • 传入变量存储位置的引用(可读写)
  • 调用方必须使用ref
  • 调用前必须对变量进行初始化
  • 方法内部可以读取参数,也可以对参数重新赋值
  • 方法内部对参数的赋值会直接修改调用方的变量
1
2
3
4
5
6
7
8
static void Change(ref int number)
{
    number = number + 1;
}

int number = 1;
Change(ref number);
Console.WriteLine(number);//2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public struct People
{
    public string name;
    public int money;

    public void MoneyAdd() => money++;

    public People(string name, int money)
    {
        this.name = name;
        this.money = money;
    }
}

static void ChangePeople(ref People people)
{
    people = new("world", 100);//会直接替换调用方变量保存的结构体值
}

People people = new("hello", 1);
ChangePeople(ref people);
Console.WriteLine(people.name);//world
Console.WriteLine(people.money);//100

ref和普通参数的区别在于:普通参数传入的是值的副本,而ref传入的是变量存储位置的引用。因此ref可以让方法直接替换调用方变量保存的值。

ref不作为方法参数

ref除了修饰方法参数,还可以用于声明引用局部变量和引用返回值。此时保存的不是变量的副本,而是变量存储位置的引用。

1
2
3
4
5
6
int[] numbers = { 1, 2, 3 };

ref int number = ref numbers[0];//number引用数组的第一个元素
number = 100;

Console.WriteLine(numbers[0]);//100

使用ref局部变量时,等号右侧也必须使用ref,表示获取目标变量的引用。后续通过这个局部变量进行读写,都会直接作用于原变量。

方法也可以返回一个变量的引用,调用方需要使用ref接收这个返回值:

1
2
3
4
5
6
7
8
9
10
static ref int FindFirst(int[] numbers)
{
    return ref numbers[0];
}

int[] numbers = { 1, 2, 3 };
ref int first = ref FindFirst(numbers);
first = 100;

Console.WriteLine(numbers[0]);//100

引用返回值必须返回一个仍然有效的变量引用,不能返回方法内部局部变量的引用。ref readonly可以返回或保存只读引用,只能读取目标变量,不能通过这个引用修改目标变量。

out

  • 传入变量存储位置的引用(输出)
  • 调用方可以传入未初始化的变量
  • 调用方必须使用out
  • 方法必须在正常返回前对参数赋值
  • 方法内部不应该依赖调用前参数中原本存在的值
1
2
3
4
5
6
7
8
9
static void GetPeople(out People people)
{
    people = new("world", 100);//必须在方法返回前赋值
}

People people;
GetPeople(out people);//调用前不需要初始化
Console.WriteLine(people.name);//world
Console.WriteLine(people.money);//100

out常用于一个方法需要返回多个结果,或者需要同时返回“是否成功”和结果值的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static bool TryGetNumber(string text, out int number)
{
    if (int.TryParse(text, out number))
    {
        return true;
    }

    number = 0;//即使失败,也必须给out参数赋值
    return false;
}

if (TryGetNumber("100", out int number))
{
    Console.WriteLine(number);//100
}

params

  • 用于接收数量不固定的参数
  • 调用方可以传入零个或多个参数
  • params参数必须是方法的最后一个参数
  • 一个方法最多只能有一个params参数
  • params参数通常是数组,方法内部会按照数组进行处理
1
2
3
4
5
6
7
8
9
10
11
12
13
static int Add(params int[] numbers)
{
    int result = 0;
    foreach (int number in numbers)
    {
        result += number;
    }

    return result;
}

Console.WriteLine(Add());//0
Console.WriteLine(Add(1, 2, 3));//6

调用方也可以直接传入一个数组:

1
2
int[] numbers = { 1, 2, 3 };
Console.WriteLine(Add(numbers));//6

使用单个参数调用时,编译器会将这些参数组合成一个数组再传入方法。params只负责简化可变数量参数的调用方式,不表示引用传递,也不能和ref、in、out一起修饰同一个参数。

泛型使用

当in、out写在泛型类型参数前面时,它们不再表示参数传递方式,而是表示泛型的变体:

  • out表示协变(covariance),只能把类型参数作为输出使用
  • in表示逆变(contravariance),只能把类型参数作为输入使用
  • 没有修饰符表示不变(invariant)

泛型变体只适用于接口和委托,不能直接用于类或结构体。并且变体转换只适用于引用类型,值类型之间不支持这种转换。

out泛型参数(协变)

协变可以理解为:子类类型可以转换成父类类型。

1
2
3
4
5
6
7
8
9
10
interface IProducer<out T>
{
    T Get();//T只作为返回值使用
}

class Animal { }
class Cat : Animal { }

IProducer<Cat> catProducer = null;
IProducer<Animal> animalProducer = catProducer;//合法,Cat可以转换为Animal

out T只能出现在返回值、只读属性等输出位置,不能出现在方法参数位置,因为接口内部不能把一个Cat生产器当成能够接收任意Animal的对象使用。

1
2
3
4
5
interface IProducer<out T>
{
    T Get();
    //void Set(T value);//非法,T不能作为输入参数
}

常见的协变类型有IEnumerable<out T>和Func<out TResult>:

1
2
3
4
5
IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings;//合法

Func<string> getString = () => "hello";
Func<object> getObject = getString;//合法

in泛型参数(逆变)

逆变可以理解为:父类类型可以转换成子类类型。

1
2
3
4
5
6
7
8
9
10
interface IConsumer<in T>
{
    void Consume(T value);//T只作为输入参数使用
}

class Animal { }
class Cat : Animal { }

IConsumer<Animal> animalConsumer = null;
IConsumer<Cat> catConsumer = animalConsumer;//合法,能够处理Animal的对象,当然也能够处理Cat

in T只能出现在方法参数、可写属性等输入位置,不能作为返回值使用。

1
2
3
4
5
interface IConsumer<in T>
{
    void Consume(T value);
    //T Get();//非法,T不能作为返回值
}

常见的逆变类型有IComparer<in T>、IEqualityComparer<in T>和Action<in T>:

1
2
Action<Animal> actionAnimal = animal => { };
Action<Cat> actionCat = actionAnimal;//合法

不变泛型参数

没有使用in或out修饰的泛型参数是不变的,即使Cat继承自Animal,IBox<Cat>也不能转换为IBox<Animal>。

1
2
3
4
5
6
7
8
interface IBox<T>
{
    T Get();
    void Set(T value);
}

IBox<Cat> catBox = null;
//IBox<Animal> animalBox = catBox;//非法,IBox<T>是不变的

之所以不允许这种转换,是因为IBox<Animal>不仅可以读取Animal,还可以写入任意Animal;如果它实际指向的是IBox<Cat>,就可能把不是Cat的对象写入其中。

本文由作者按照 CC BY 4.0 进行授权