[.NET大牛之路 016] C# 基础:理解方法及其参数

今天我们来详细讲讲 C# 中的方法,我们每天都要和它打交道。方法(Method)是由访问修饰符和返回类型(无返回类型则为 void)定义的,可以接受也可以不接受参数。通常我们可以把有返回值的方法称为函数(Function),函数是方法的子集。

每个方法都有以下基本格式:

class Program
{
  // <访问修饰符> 返回类型 方法名(<参数1>, <参数2>, ……) { /* 实现 */ }
  public int Add(int x, int y)
  {
    return x + y;
  }
}

方法可以在类、结构或接口(C# 8)中实现。

01 方法表达式体

有时候方法体中只有一句代码,可以使用 C# 的方法表达式体(Expression-Bodied),使代码更简洁,比如上面的 Add() 方法使用表达式体可以写成:

public int Add(int x, int y) => x + y;

这样整个方法实现只有一行代码,这就是通常所说的句法糖而已,其背后生成的 IL 代码没什么不同。当然也有人认为它不易阅读,这看个人或团队的喜好了。这里的 => 我们把它叫作 lambda 操作符。

02 本地函数

C# 支持在方法中定义方法,我们称之为局部函数,或本地函数。本地函数必须是私有的,可以是静态的,不支持重载。

举个例子:

public int AddWrapper(int x, int y)
{
  // do something here
  return Add();

  int Add()
  {
    return x + y;
  }

  // 或
  // int Add() => x + y;
}

AddWrapper() 方法内的包含的 Add() 方法只能在 AddWrapper() 方法内调用。本地函数的好处是,它不会暴露在父方法之外。

本地函数支持嵌套,即:一个本地函数可以在其内部再声明一个本地函数。本地函数也可以是没有返回值的本地方法。

另外,C# 9.0 允许为本地函数及其参数添加特性,如下面的例子:

#nullable enable
private static void Process(string?[] lines)
{
  foreach (var line in lines)
  {
    if (IsValid(line))
    {
      // ...
    }
  }
  bool IsValid([NotNullWhen(true)] string? line)
  {
    return !string.IsNullOrEmpty(line) && line.Length >= 6;
  }
}

03 静态本地函数

本地函数也可以是静态的。在前面的例子中,本地函数 Add() 直接引用了父函数中的变量,这可能会发生意想不到的副作用,因为本地函数可以改变参数的值,例如:

int AddWrapper(int x, int y)
{
  // do something here
  return Add();

  int Add()
  {
    x += 1; // 副作用
    return x + y;
  }
}

为了防止这种情况发生,在本地函数中可以加上 static 修饰符,这样可以防止本地函数直接访问父方法变量,如果访问则会引发编译错误,这对函数式编程非常有用。

 Add() 方法变成静态方法,不能使用父方法中的变量,则需要定义其所需参数:

int AddWrapper(int x, int y)
{
  // do something here
  return Add(x, y);

  static int Add(int x, int y)
  {
    x += 1; // 副作用
    return x + y;
  }
}

04 方法的形参和实参

方法中的参数分为实际参数和形式参数,实际参数简称为实参,是在调用方法时传递的参数;形式参数简称为形参,是在方法定义中所写的参数。例如:

int Add(int x, int y)
{
  return x + y;
}

在上面的方法定义中, x 和  y 是形参。在下面的代码中调用 Add() 方法:

static void Main(string[] args)
{
    int result = Add(1, 2);
    Console.WriteLine(result);
}

在调用 Add() 方法时传递的参数 1  2 就是实参

05 参数的传值方式

方法的参数用来向方法调用传递数据,传递的方式有两种:按值传递按引用传递。默认的一般规则是,值类型的参数按值传递,引用类型的参数按引用传递。

按值传递会将数据的副本传给函数参数,方法内改变参数的值不会影响方法外面的原始(实参)变量的值,例如:

class Program
{
    static void Main(string[] args)
    {
        int x = 9, y = 10;
        Console.WriteLine("调用前:X: {0}, Y: {1}", x, y);
        Console.WriteLine("结果值: {0}", Add(x, y));
        Console.WriteLine("调用后: X: {0}, Y: {1}", x, y);

        Console.ReadLine();
    }

    static int Add(int x, int y)
    {
        int result = x + y;
        x = 10000;
        y = 88888;
        return result;
    }
}

输出:

调用前:X: 9, Y: 10
结果值: 19
调用后: X: 9, Y: 10

这里,在调用 Add() 之前和之后, x 和  y 的值保持一致。在 Add() 方法中这些参数的任何变化都不会影响到外面的调用者,因为对值类型来说,方法是在数据的副本上操作的。

如果参数的类型是引用类型,在传递对象给方法时,对象的属性按引用传递,而对象本身是按值传递的。当修改形参对象的属性时,就等于修改了调用者对应的实参。举个例子:

class Program
{
    static void Main(string[] args)
    {
        XY xy = new() { X = 10, Y = 20 };

        Console.WriteLine("调用前:xy.X: {0}, xy.Y: {1}", xy.X, xy.Y);
        Console.WriteLine("结果值: {0}", Add(xy));
        Console.WriteLine("调用后: xy.X: {0}, xy.Y: {1}", xy.X, xy.Y);

        Console.ReadLine();
    }

    static int Add(XY obj)
    {
        int result = obj.X + obj.Y;
        obj.X = 100;
        obj.Y = 200;
        return result;
    }
}

class XY
{
    public int X { get; set; }
    public int Y { get; set; }
}

输出:

调用前:xy.X: 10, xy.Y: 20
结果值: 30
调用后: xy.X: 100, xy.Y: 200

值得注意的是,字符串类型的参数比较特殊,它是引用类型,但它是按值传递的。

06 参数的修饰符

方法参数除了默认的按值传递和按引用传递行为,你也可以通过使用修饰符来控制参数的传递行为。方法参数的修饰符主要有四个: outrefin 和  params

out 修饰符

 out 修饰的参数被称为输出参数。定义了接受输出参数的方法有义务在退出方法之前给它们分配给一个适当的值,否则会报错。

示例:

static void AddWithOut(int x, int y, out int ans)
{
  ans = x + y;
}

要调用 AddWithOut() 方法,需在调用时使用 out 修饰符,例如:

int ans;
AddWithOut(10, 20, out ans);
// ans = 30

由于被调用的方法必须对输出参数赋值,作为输出参数传递的本地变量在传入之前不需要进行赋值(即使赋值了,原始值在调用后也会被覆盖)。

输出参数的变量声明也可以直接写在调用方法中,例如:

AddWithOut(10, 20, out int ans);
// ans = 30

如果你不关心某个输出参数的值,你可以使用弃元操作符 _。例如,你想得到第一个参数值,但不关心后两个参数,你可以这样写:

FillValues(out int a, out _, out _);

ref 修饰符

用 ref 修饰的参数被称为引用参数。当你想让一个方法对调用者的变量进行操作(改变其值)时,可以使用引用参数,比如对数据进行排序或交换值。注意输出参数和引用参数之间的区别:

  • 输出参数在被传递给方法之前不需要被初始化,该方法必须在退出前给输出参数赋值。
  • 引用参数必须在传递给方法之前被初始化,因为如果你不给它分配一个初始值,那就相当于操作一个未分配的局部变量。

下面是使用引用参数交换两个字符串变量的值的例子(这里可以使用其它类型,如 int、bool、float 等):

static void Main(string[] args)
{
    string str1 = "Foo";
    string str2 = "Bar";
    Console.WriteLine("Before: {0}, {1} ", str1, str2);
    SwapStrings(ref str1, ref str2);
    Console.WriteLine("After: {0}, {1} ", str1, str2);

    Console.ReadLine();
}

static void SwapStrings(ref string s1, ref string s2)
{
    string temp = s1;
    s1 = s2;
    s2 = temp;
}

输出:

交换前: Foo, Bar
交换后: Bar, Foo

in 修饰符

我们知道,按值传参可以保证调用者的变量不被方法修改,因为它传递的是数据副本。如果值类型的数据较大(比如一个大的结构体),则需内存的分配也会较大。按引用传参,调用者传递的对象可能被方法修改。

如果既要保证参数不被方法修改,又不想按值传递,则可以使用 in 参数。用 in 修饰的参数是通过引用的方式传值的(值类型和引用类型均可),并可以防止被方法修改这些值。

static int AddReadOnly(in int x, in int y)
{
    // 尝试修改参数的值会报错
    // x = 999;
    return x + y;
}

params 修饰符

C# 中的 params 关键字允许你将数量不等的相同类型(或有继承关系)的参数作为一个单一的逻辑参数传入一个方法。它支持调用者传入一个数组或一个以逗号分隔的参数列表。比如,你想创建一个函数,允许调用者传入任意数量的参数并返回计算出的平均值,则 params 修饰符非常适用:

static void Main(string[] args)
{
    CalcAvg(1, 2, 3, 4, 5);

    Console.ReadLine();
}

static double CalcAvg(params double[] values)
{
    if (values.Length == 0)
    {
        return 0;
    }

    double sum = 0;
    foreach (double value in values)
    {
        sum += value;
    }
    return sum / values.Length;
}

07 可选参数

C# 允许你创建可以接受可选参数的方法,只要方法指定的默认值是调用者需要的,则调用时可省略已提供默认值的参数。例如:

static void Main(string[] args)
{
    LogError("Oh no!");
    LogError("Oh no!", "Tester");

    Console.ReadLine();
}

static void LogError(string message, string owner = "System")
{
    Console.Beep();
    Console.WriteLine("Error: {0}", message);
    Console.WriteLine("Owner of Error: {0}", owner);
}

这里,最后一个字符串参数提供了默认值 System,因此,你可以用两种方式调用 LogError()。调用时可选参数可提供也可不提供,不提供则使用默认值。

需要注意是,分配给可选参数的值必须在编译时就知道,不能延迟到运行时,否则会报编译器错误,例如:

static void LogError(string message,
    string owner = "System",
    DateTime time = DateTime.Now) // 报错
{
    Console.Beep();
    Console.WriteLine("Error: {0}", message);
    Console.WriteLine("Owner of Error: {0}", owner);
    Console.WriteLine("Time of Error: {0}", time);
}

这里的 DateTime.Now 只在运行时才能被解析,而不能编译时解析,所以会报编译器错误。

为了避免歧义,可选参数必须总是放在方法参数的最后,将可选参数列在非可选参数之前也会报编译器错误。

08 命名参数

默认情况,调用方法时,参数的传递是按位置匹配的。在 C# 中,通过使用命名参数允许你以任何顺序指定参数值来调用一个方法。你可以选择使用 : 操作符来指定每个参数的名称。例如:

static void Main(string[] args)
{
  DisplayMessage(message: "Test...",
    textColor: ConsoleColor.DarkRed,
    backgroundColor: ConsoleColor.White);
  DisplayMessage(backgroundColor: ConsoleColor.Green,
    message: "Test...",
    textColor: ConsoleColor.DarkBlue);
  Console.ReadLine();
}

static void DisplayMessage(ConsoleColor textColor,
  ConsoleColor backgroundColor, string message)
{
  ConsoleColor oldTextColor = Console.ForegroundColor;
  ConsoleColor oldbackgroundColor = Console.BackgroundColor;

  Console.ForegroundColor = textColor;
  Console.BackgroundColor = backgroundColor;
  Console.WriteLine(message);

  Console.ForegroundColor = oldTextColor;
  Console.BackgroundColor = oldbackgroundColor;
}

相对于命名参数,按照位置匹配的非命名参数我们可以称它为位置参数。命名参数和位置参数在调用时可以混合使用,但位置参数必须放在对应的位置上,例如:

// OK
DisplayMessage(ConsoleColor.Blue,
  message: "Testing...",
  backgroundColor: ConsoleColor.White);
// OK
DisplayMessage(textColor: ConsoleColor.White,
  backgroundColor: ConsoleColor.Blue,
  "Testing...");
// Error
DisplayMessage(message: "Testing...",
  backgroundColor: ConsoleColor.White,
  ConsoleColor.Blue);

09 方法的重载

简单地说,当你定义了一组名称相同的方法,但参数的数量(或类型)不同时,这些方法就被称为重载。例如下面的两个 Add() 方法:

public int Add(int x, int y)
{
  return x + y;
}
public double Add(double x, double y)
{
  return x + y;
}

调用这两个:

Console.WriteLine(Add(10, 10));
Console.WriteLine(Add(4.3, 4.4));

这里调用了两个不同版本的 Add() 方法,每个版本使用不同的数据类型。

本文来自投稿,不代表Chaoqiang's Blog立场,如若转载,请注明出处:https://www.zhengchaoqiang.com/1029.html

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注

近期个人博客正在迁移中,原博客请移步此处,抱歉!