C# 引用参数

最近经常和同事讨论引用参数的问题,为了搞清楚,查了些资料,其中CLR via C#中讲的比较清楚,整理了下

----摘自(CLR via C#)

在默认情况下,CLR假设所有的方法参数都是按值传递的。当参数为引用类型的对象时,参数的传递时通过传递指向对象的引用来完成的(引用本身是按值传递的)。这意味着方法可以改变引用对象,并且调用代码可以看到这种改变的结果。

对于一个方法,我们必须知道它的每个参数是引用类型参数,还是值类型的参数,因为我们编写的操作参数的代码会因此有很大的差别。

除了按值传递参数外,CLR还允许我们按引用的方式来才传递参数。在C#中,我们可以用out和ref关键字来做到这一点。这两个关键字告诉C#编译器要产生额外的元数据来表示指定参数是按引用的方式来传递的:编译器将使用该信息来产生传递参数地址(而不是参数本身的值)的代码。

关键字out和ref的不同之处在于哪个方法负责初始化参数。如果一个方法的参数被标识为out,那么调用代码在调用该方法之前可以不初始化该参数,并且被调用方法不能直接读取参数的值,它必须在返回之前为该参数赋值。如果一个方法的参数被标识为ref,那么调用代码在调用该方法之前必须首先初始化该参数。被调用方法则可以任意选择读取该参数、或者为该参数赋值。

引用类型参数和值类型参数在使用out和ref关键字时的行为有很大的区别。下面我们先来看一看在值类型参数上使用out关键字时的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Program
    {
        static void Main(string[] args)
        {
            Int32 x;
            SetVal(out x);       //x不必被初始化
            Console.WriteLine(x); //显示“10”
 
        }
 
        static void SetVal(out Int32 v)
        {
            v = 10; ;//SetVal方法必须初始化
        }
}

在上面的代码中,x首先被声明在线程的堆栈上。接着,x的地址被传递给SetVal。SetVal的参数v是一个指向Int32值类型的指针。在SetVal内部,v指向的Int32被赋值为10。当SetVal返回后,Main中的x的值将为10,控制台上的结果自然也将为“10”。在值类型参数上使用out关键字会为代码带来一定的效率提升,因为他避免了值类型实例的字段在方法调用时的拷贝操作。

我们再来看一看在值类型参数上使用ref关键字时的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Program
    {
        static void Main(string[] args)
        {
            Int32 x = 5;
            AddVal(ref x);//x必被初始化
 
            Console.WriteLine(x); //显示“15”
          
        }
 
        static void AddVal(ref Int32 v)
        {
            v += 10;  //AddVal方法可以直接使用经过初始化的v
        }
 
        
    }

在上面的代码中,x首先被声明在线程的堆栈上,紧接着便初始化为5。随后x的地址被传递给AddVal。AddVal的参数v是一个指向Int32值类型的指针。在AddVal内部,v指向的Int32必须为一个经过初始化的值。这样AddVal才可以在任何表达式中使用该初始值,也可以改变它,并且改变后的值会被“返回”给调用代码。在上面的例子中,AddVal将10加到该初始值上。当AddVal返回后, Main中x的值将为15,自然在控制台上显示的结果也将为“15”。

从IL或者CLR的角度来看,out和ref关键字的行为实际上是一样的:它们都会导致指向实例的指针被传递给方法。两者的不同之处在于编译器会根据它们选择不同的机制来确保我们的代码是正确的,例如,下面的代码视图向一个需要ref参数的方法传递一个未经初始化的值,从而导致编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Program
    {
        static void Main(string[] args)
        {
            Int32 x; //x没有被初始化
 
            //下面一行将导致编译失败,编译器将产生错误信息
            //error cs0165:使用了未赋值的局部变量‘x’
            AddVal(ref x);//x必被初始化
            Console.WriteLine(x); //显示“15”        
        }
 
        static void AddVal(ref Int32 v)
        {
            v += 10;  //AddVal方法可以直接使用经过初始化的v
        }
    }

 

另外,CLR允许我们根据out和ref参数来重载方法。例如,下面的代码就是合法的:

1
2
3
4
5
6
7
8
9
class Point
 
{
 
static void Add(Point p){ }
 
static void Add(ref Point p) { }
 
}

但是仅通过区分out和ref来重载方法又是不合法的,因为它们经JIT编译后的代码是相同。所以我们不能在上面的point类型中再定义下面的方法:

1
static void Add(out Point p) { }

 

在值类型参数上使用out和ref关键字与用传值的方式来传递引用类型的参数在某种程度上具有相同的行为,对于前一种情况,out和ref关键字允许被调用方法直接操作一个值类型实例。调用代码必须为该实例分配内存,而被调用方法操作该内存。对于后一种情况,调用代码负责为引用类型对象分配内存,而被调用方法通过传入的引用来操作对象。基于这种行为,只有当一个方法要“返回”一个它已知的对象引用时,在引用类型参数上使用out和ref关键字才有意义。看下面的代码:

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
32
33
class App
    {
        static public void Main()
        {
            FileStream fs;
 
            //打开第一个待处理文件
            StartProcessingFiles(out fs);
 
            //如果有更多需要处理的文件,则继续
            for (; fs!=null; ContinueProcessingFiles(ref fs))
            {
                //处理文件
                fs.Read(...);
            }
             
        }
 
        static void StartProcessingFiles(out FileStream fs)
        {
            fs=new FileStream(...);
        }
 
        static void ContinueProcessingFiles(ref FileStream fs)
        {
            fs.Close();//关闭上一次操作的文件
 
            //打开下一个文件:如果没有文件,则返回null
            if (noMoreFilesToProcess) fs = null;
            else fs=new FileStream(...);
 
        }
    }

 

如我们所见,这段代码中最大的不同在于有着out或者ref修饰的引用类型参数的方法创建一个对象后,指向新对象的指针会被返回给调用代码。另外注意ContinueProcessingFiles方法在返回新对象之前可以操作传入的对象,这是因为其参数被标识为ref。

下面的代码是上述代码的一个简化版本:

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
class App
    {
        static public void Main()
        {
            FileStream fs = null;//初始化为null(必要的操作)
            //打开第一个待处理文件
            ProcessingFiles(ref fs);
            for (; fs != null; ProcessingFiles(ref fs))
            {
                //处理文件
                fs.Read(...);
            }
 
        }
        static void ProcessingFiles(ref FileStream fs)
        {
            //如果先前的文件打开的,则将其关闭
            if (fs != null) fs.Close(); //关闭上一次操作的文件
 
            //打开下一个文件:如果没有文件,则返回null
 
            if (noMoreFilesToProcess) fs = null;
            else fs=new FileStream(...);
        }
}

下面的例子演示了怎样使用ref关键字来交换两个引用类型:

1
2
3
4
5
6
static public void Swap(ref object a, ref object b)
       {
           object t = b;
           b = a;
           a = t;
       }

 

要交换两个String对象引用,大家可能会考虑像下面怎样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static public void SomeMethod()
 
{
 
string s1 = "jeff";
 
string s2 = "rich";
 
Swap(ref s1, ref s2);
 
Console.WriteLine(s1);//显示rich
 
Console.WriteLine(s2);//显示jeff
 
}

可以看到,修正后的SomeMethod会通过编译,并且会按我们所期望的行为执行,C#要求以引用方式传递的参数必须和方法期望的参数完全匹配的目的是为了确保类型安全。下面的代码展示了如果类型不匹配可能导致类型安全漏洞(不会通过编译)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SomeType
    {
        public int val;
 
    }
    class App
    {
        static void Main()
        {
            SomeType st;
 
            //下面一行将产生编译错误error cs1503: 参数‘1’:
            //无法从‘out SomeType’转换为‘out object’
            GetAnObject(out st);
 
            Console.WriteLine(st.val);
        }
        static void GetAnObject(out object o)
        {
            o = new string('X', 100);
        }
 
    }

 

在这段代码中,Main期望GetAnObject返回一个SomeType对象。但是,因为GetAnObject得签名表示的是一个指向object的引用,所以GetAnObject可以将o初始化为一个任何类型的对象。当GetAnObject返回到Main中时,st将指向一个string,这显然不是一个SomeType对象,对Console.WriteLine的调用自然会失败。幸运的是,C#编译器不会编译上面的代码,因为st是一个指向SomeType的引用,而GetAnObject要求的是指向object的引用

posted @ 2016-09-22 14:33  yzl050819  阅读(268)  评论(0编辑  收藏  举报