จับตัวแปรในวงใน C #


216

ฉันพบปัญหาที่น่าสนใจเกี่ยวกับ C # ฉันมีรหัสเช่นด้านล่าง

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    actions.Add(() => variable * 2);
    ++ variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

ฉันคาดหวังให้มันส่งออก 0, 2, 4, 6, 8 อย่างไรก็ตามจริงๆแล้วมันส่งออกห้า 10s

ดูเหมือนว่าเกิดจากการกระทำทั้งหมดที่อ้างถึงตัวแปรที่ถูกจับหนึ่งตัว ดังนั้นเมื่อพวกเขาได้รับการเรียกพวกเขาทั้งหมดมีผลลัพธ์ที่เหมือนกัน

มีวิธีแก้ไขข้อ จำกัด นี้เพื่อให้แต่ละอินสแตนซ์การดำเนินการมีตัวแปรที่จับภาพได้หรือไม่?


15
ดูชุดบล็อกของ Eric Lippert ในหัวข้อ: การปิดการวนรอบตัวแปรถือว่าเป็นอันตราย
Brian

10
นอกจากนี้พวกเขากำลังเปลี่ยน C # 5 ให้ทำงานตามที่คุณคาดหวังไว้ใน foreach (เปลี่ยนการแตกหัก)
Neal Tibrewala


3
@ จริง: แม้ว่าตัวอย่างนี้ยังไม่ทำงานอย่างถูกต้องใน C # 5 เพราะมันยังออกห้า 10s
เอียน Oakes

6
มันตรวจสอบว่ามันส่งออกห้า 10s จนถึงวันนี้ใน C # 6.0 (VS 2015) ฉันสงสัยว่าพฤติกรรมการปิดตัวแปรนี้เป็นตัวเลือกสำหรับการเปลี่ยนแปลง Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.
RBT

คำตอบ:


196

ใช่ - คัดลอกตัวแปรภายในลูป:

while (variable < 5)
{
    int copy = variable;
    actions.Add(() => copy * 2);
    ++ variable;
}

คุณสามารถคิดได้ว่าคอมไพเลอร์ C # สร้างตัวแปรท้องถิ่น "ใหม่" ทุกครั้งที่มันฮิตประกาศตัวแปร ในความเป็นจริงมันจะสร้างวัตถุปิดใหม่ที่เหมาะสมและมันจะซับซ้อน (ในแง่ของการนำไปใช้) ถ้าคุณอ้างถึงตัวแปรในขอบเขตหลาย ๆ อัน แต่มันก็ใช้งานได้ :)

โปรดทราบว่ามีการใช้ปัญหานี้บ่อยขึ้นforหรือforeach:

for (int i=0; i < 10; i++) // Just one variable
foreach (string x in foo) // And again, despite how it reads out loud

ดูหัวข้อ 7.14.4.2 ของข้อกำหนด C # 3.0 สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับเรื่องนี้และบทความของฉันเกี่ยวกับการปิดมีตัวอย่างมากขึ้นเช่นกัน

โปรดทราบว่าในฐานะที่เป็นคอมไพเลอร์ C # 5 และเกิน (แม้เมื่อระบุรุ่นก่อนหน้าของ C #) พฤติกรรมของการforeachเปลี่ยนแปลงดังนั้นคุณไม่จำเป็นต้องทำสำเนาในเครื่องอีกต่อไป ดูคำตอบนี้สำหรับรายละเอียดเพิ่มเติม


32
หนังสือของจอนยังมีบทที่ดีมากเกี่ยวกับเรื่องนี้ (หยุดความอ่อนน้อมถ่อมตนจอน!)
Marc Gravell

35
มันจะดูดีกว่าถ้าฉันปล่อยให้คนอื่นเสียบ;) (ฉันขอสารภาพว่าฉันมักจะโหวตคำตอบที่แนะนำแม้ว่า)
Jon Skeet

2
เช่นเคยข้อเสนอแนะเพื่อ skeet@pobox.com จะได้รับการชื่นชม :)
จอนสกีต

7
สำหรับพฤติกรรม C # 5.0 นั้นแตกต่างกัน (สมเหตุสมผลมากขึ้น) ดูคำตอบที่ใหม่กว่าโดย Jon Skeet - stackoverflow.com/questions/16264289/…
Alexei Levenkov

1
@ Florimond: นั่นไม่ใช่วิธีการปิดใน C # พวกเขาจับตัวแปรไม่ได้ค่า (มันเป็นเรื่องจริงโดยไม่คำนึงถึงลูปและสามารถแสดงให้เห็นได้อย่างง่ายดายด้วยแลมบ์ดาที่จับตัวแปรและเพียงพิมพ์ค่าปัจจุบันทุกครั้งที่มันถูกประมวลผล)
Jon Skeet

23

ผมเชื่อว่าสิ่งที่คุณกำลังประสบคือสิ่งที่รู้จักกันในนามปิดhttp://en.wikipedia.org/wiki/Closure_(computer_science) lamba ของคุณมีการอ้างอิงถึงตัวแปรที่อยู่นอกขอบเขตของฟังก์ชัน lamba ของคุณจะไม่ถูกตีความจนกว่าคุณจะเรียกใช้และเมื่อมันจะได้รับค่าตัวแปรที่มีในเวลาดำเนินการ


11

เบื้องหลังคอมไพเลอร์กำลังสร้างคลาสที่แสดงถึงการปิดสำหรับการเรียกใช้เมธอดของคุณ มันใช้อินสแตนซ์เดียวของคลาสการปิดสำหรับการวนซ้ำแต่ละครั้งของลูป โค้ดมีลักษณะดังนี้ซึ่งทำให้ง่ายต่อการดูว่าทำไมข้อผิดพลาดเกิดขึ้น:

void Main()
{
    List<Func<int>> actions = new List<Func<int>>();

    int variable = 0;

    var closure = new CompilerGeneratedClosure();

    Func<int> anonymousMethodAction = null;

    while (closure.variable < 5)
    {
        if(anonymousMethodAction == null)
            anonymousMethodAction = new Func<int>(closure.YourAnonymousMethod);

        //we're re-adding the same function 
        actions.Add(anonymousMethodAction);

        ++closure.variable;
    }

    foreach (var act in actions)
    {
        Console.WriteLine(act.Invoke());
    }
}

class CompilerGeneratedClosure
{
    public int variable;

    public int YourAnonymousMethod()
    {
        return this.variable * 2;
    }
}

นี่ไม่ใช่รหัสที่คอมไพล์จากตัวอย่างของคุณ แต่ฉันได้ตรวจสอบโค้ดของตัวเองแล้วและนี่ดูเหมือนว่าคอมไพเลอร์จะสร้างอะไรขึ้นมาจริงๆ


8

วิธีนี้คือการเก็บค่าที่คุณต้องการในตัวแปรพร็อกซีและรับค่าตัวแปรนั้น

IE

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}

ดูคำอธิบายในคำตอบที่แก้ไขแล้วของฉัน ตอนนี้ฉันกำลังหาข้อมูลจำเพาะที่เกี่ยวข้อง
Jon Skeet

ฮ่าฮ่าจอนฉันเพิ่งอ่านบทความของคุณ: csharpindepth.com/Articles/Chapter5/Closures.aspx คุณทำงานได้ดีกับเพื่อนของฉัน
tjlevine

@tjlevine: ขอบคุณมาก ฉันจะเพิ่มการอ้างอิงถึงสิ่งนั้นในคำตอบของฉัน ฉันลืมไปแล้ว!
Jon Skeet

นอกจากนี้จอนฉันชอบที่จะอ่านเกี่ยวกับความคิดของคุณเกี่ยวกับข้อเสนอการปิด Java 7 ต่างๆ ฉันเคยเห็นคุณพูดถึงว่าคุณต้องการที่จะเขียน แต่ฉันไม่ได้เห็นมัน
tjlevine

1
@tjlevine: เอาล่ะฉันสัญญาว่าจะพยายามที่จะเขียนมันขึ้นมาในตอนท้ายของปี :)
จอนสกีต

6

นี่ไม่เกี่ยวอะไรกับลูป

ลักษณะการทำงานนี้เกิดขึ้นเนื่องจากคุณใช้นิพจน์แลมบ์ดา() => variable * 2ซึ่งขอบเขตด้านนอกvariableไม่ได้กำหนดในขอบเขตภายในของแลมบ์ดา

นิพจน์แลมบ์ดา (ใน C # 3 + รวมถึงวิธีที่ไม่ระบุตัวตนใน C # 2) ยังคงสร้างวิธีการที่แท้จริง การส่งผ่านตัวแปรไปยังวิธีการเหล่านี้เกี่ยวข้องกับประเด็นขัดแย้งบางอย่าง (ผ่านโดยการส่งผ่านค่าอ้างอิงโดยการอ้างอิง C # ไปด้วยโดยการอ้างอิง - แต่จะเปิดปัญหาอื่นที่การอ้างอิงสามารถอยู่ได้นานกว่าตัวแปรจริง) สิ่งที่ C # ทำเพื่อแก้ไขปัญหาทั้งหมดนี้คือการสร้างคลาสตัวช่วย ("การปิด") พร้อมฟิลด์ที่สอดคล้องกับตัวแปรท้องถิ่นที่ใช้ในการแสดงออกแลมบ์ดาและวิธีการที่สอดคล้องกับวิธีแลมบ์ดาจริง การเปลี่ยนแปลงใด ๆvariableในรหัสของคุณได้รับการแปลให้เปลี่ยนแปลงจริงClosureClass.variable

ดังนั้นในขณะที่วงของคุณช่วยปรับปรุงClosureClass.variableจนกว่าจะถึง 10 ClosureClass.variableแล้วสำหรับการดำเนินการกระทำที่ลูปซึ่งทั้งหมดทำงานในที่เดียวกัน

เพื่อให้ได้ผลลัพธ์ที่คาดหวังคุณต้องสร้างการแยกระหว่างตัวแปรลูปและตัวแปรที่กำลังปิด คุณสามารถทำได้โดยการแนะนำตัวแปรอื่นเช่น:

List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
while (variable < 5)
{
    var t = variable; // now t will be closured (i.e. replaced by a field in the new class)
    actions.Add(() => t * 2);
    ++variable; // changing variable won't affect the closured variable t
}
foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

คุณสามารถย้ายการปิดไปยังวิธีอื่นเพื่อสร้างการแยกนี้:

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    actions.Add(Mult(variable));
    ++variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

คุณสามารถใช้ Mult เป็นนิพจน์แลมบ์ดา (การปิดโดยปริยาย)

static Func<int> Mult(int i)
{
    return () => i * 2;
}

หรือกับคลาสตัวช่วยจริง:

public class Helper
{
    public int _i;
    public Helper(int i)
    {
        _i = i;
    }
    public int Method()
    {
        return _i * 2;
    }
}

static Func<int> Mult(int i)
{
    Helper help = new Helper(i);
    return help.Method;
}

ไม่ว่าในกรณีใดก็ตาม"การปิด" ไม่ใช่แนวคิดที่เกี่ยวข้องกับลูปแต่เป็นวิธีการที่ไม่ระบุตัวตน / การแสดงออกแลมบ์ดาใช้ตัวแปรที่มีการกำหนดขอบเขตเฉพาะที่ - แม้ว่าการใช้ลูปอย่างระมัดระวังบางครั้ง


5

ใช่คุณต้องกำหนดขอบเขตvariableภายในลูปและส่งไปยังแลมบ์ดาด้วยวิธีนี้:

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    int variable1 = variable;
    actions.Add(() => variable1 * 2);
    ++variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

Console.ReadLine();

5

สถานการณ์เดียวกันเกิดขึ้นในหลายเธรด (C #, .NET 4.0]

ดูรหัสต่อไปนี้:

วัตถุประสงค์คือเพื่อพิมพ์ 1,2,3,4,5 ตามลำดับ

for (int counter = 1; counter <= 5; counter++)
{
    new Thread (() => Console.Write (counter)).Start();
}

ผลลัพธ์น่าสนใจ! (อาจเป็นเช่น 21334 ... )

ทางออกเดียวคือการใช้ตัวแปรท้องถิ่น

for (int counter = 1; counter <= 5; counter++)
{
    int localVar= counter;
    new Thread (() => Console.Write (localVar)).Start();
}

ดูเหมือนจะไม่ช่วยฉัน ยังไม่สามารถกำหนดได้
Mladen Mihajlovic

0

เนื่องจากไม่มีใครที่นี่โดยตรงอ้างECMA-334 :

10.4.4.10 สำหรับข้อความ

การตรวจสอบการมอบหมายที่ชัดเจนสำหรับแบบฟอร์มคำสั่ง:

for (for-initializer; for-condition; for-iterator) embedded-statement

จะทำราวกับว่าคำสั่งถูกเขียน:

{
    for-initializer;
    while (for-condition) {
        embedded-statement;
    LLoop: for-iterator;
    }
}

เพิ่มเติมในสเป็ค

12.16.6.3 การเริ่มต้นของตัวแปรท้องถิ่น

ตัวแปรโลคัลถูกพิจารณาว่าเป็นอินสแตนซ์เมื่อการประมวลผลเข้าสู่ขอบเขตของตัวแปร

[ตัวอย่าง: ตัวอย่างเช่นเมื่อมีการเรียกใช้เมธอดต่อไปนี้ตัวแปรโลคัลxจะถูกสร้างและเริ่มต้นสามครั้ง - หนึ่งครั้งสำหรับการวนซ้ำแต่ละครั้งของลูป

static void F() {
  for (int i = 0; i < 3; i++) {
    int x = i * 2 + 1;
    ...
  }
}

อย่างไรก็ตามการย้ายการประกาศของxนอกลูปจะส่งผลให้เกิดการอินสแตนซ์เดียวของx:

static void F() {
  int x;
  for (int i = 0; i < 3; i++) {
    x = i * 2 + 1;
    ...
  }
}

ตัวอย่างท้าย]

เมื่อไม่ถูกดักจับไม่มีวิธีสังเกตว่าตัวแปรเฉพาะที่มีการสร้างอินสแตนซ์บ่อยครั้งเพียงใดเนื่องจากอายุการใช้งานของการสร้างอินสแตนซ์นั้นไม่ต่อเนื่องกันจึงเป็นไปได้ที่แต่ละอินสแตนซ์ อย่างไรก็ตามเมื่อฟังก์ชั่นที่ไม่ระบุชื่อจับตัวแปรท้องถิ่นผลของการสร้างอินสแตนซ์จะชัดเจนขึ้น

[ตัวอย่าง: ตัวอย่าง

using System;

delegate void D();

class Test{
  static D[] F() {
    D[] result = new D[3];
    for (int i = 0; i < 3; i++) {
      int x = i * 2 + 1;
      result[i] = () => { Console.WriteLine(x); };
    }
  return result;
  }
  static void Main() {
    foreach (D d in F()) d();
  }
}

ผลิตผลลัพธ์:

1
3
5

อย่างไรก็ตามเมื่อการประกาศของxถูกย้ายนอกลูป:

static D[] F() {
  D[] result = new D[3];
  int x;
  for (int i = 0; i < 3; i++) {
    x = i * 2 + 1;
    result[i] = () => { Console.WriteLine(x); };
  }
  return result;
}

ผลลัพธ์คือ:

5
5
5

โปรดทราบว่าคอมไพเลอร์ได้รับอนุญาต (แต่ไม่จำเป็น) เพื่อปรับแต่งอินสแตนซ์สามตัวให้เป็นอินสแตนซ์ของผู้รับมอบสิทธิ์เดียว (§11.7.2)

หาก for-loop ประกาศตัวแปรการวนซ้ำตัวแปรนั้นจะถูกพิจารณาว่าถูกประกาศภายนอกลูป [ตัวอย่าง: ดังนั้นหากตัวอย่างมีการเปลี่ยนแปลงเพื่อจับตัวแปรการทำซ้ำตัวเอง:

static D[] F() {
  D[] result = new D[3];
  for (int i = 0; i < 3; i++) {
    result[i] = () => { Console.WriteLine(i); };
  }
  return result;
}

อินสแตนซ์เดียวของตัวแปรการวนซ้ำถูกจับซึ่งสร้างผลลัพธ์:

3
3
3

ตัวอย่างท้าย]

โอ้ใช่ฉันเดาว่าควรพูดถึงว่าใน C ++ ปัญหานี้จะไม่เกิดขึ้นเพราะคุณสามารถเลือกได้ว่าตัวแปรถูกจับตามค่าหรือตามการอ้างอิง (ดู: การจับ Lambda )


-1

เรียกว่าปัญหาการปิดเพียงใช้ตัวแปรการคัดลอกและเสร็จสิ้น

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    int i = variable;
    actions.Add(() => i * 2);
    ++ variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

4
คำตอบของคุณแตกต่างจากคำตอบของบางคนข้างต้นอย่างไร
Thangadurai
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.