Java / C#为什么不能实现RAII?


29

问题:Java / C#为什么不能实现RAII?

澄清:我知道垃圾收集器不是确定性的。因此,使用当前的语言功能,不可能在范围出口处自动调用对象的Dispose()方法。但是,可以添加这种确定性功能吗?

我的理解:

我认为RAI​​I的实现必须满足两个要求:
1.资源的生存期必须绑定到范围。
2.隐式的。必须在没有程序员明确声明的情况下释放资源。类似于垃圾回收器无需显式语句即可释放内存。“隐含性”仅需要在使用该类时发生。当然,类库创建者必须显式实现析构函数或Dispose()方法。

Java / C#满足点1。在C#中,可以将实现IDisposable的资源绑定到“使用”范围:

void test()
{
    using(Resource r = new Resource())
    {
        r.foo();
    }//resource released on scope exit
}

这不满足第2点。程序员必须将对象明确绑定到特殊的“使用”范围。程序员可能(并且确实)忘记将资源显式绑定到作用域,从而造成泄漏。

实际上,“使用”块已由编译器转换为try-finally-dispose()代码。它具有与try-finally-dispose()模式相同的显式性质。如果没有隐式发布,则作用域的钩子就是语法糖。

void test()
{
    //Programmer forgot (or was not aware of the need) to explicitly
    //bind Resource to a scope.
    Resource r = new Resource(); 
    r.foo();
}//resource leaked!!!

我认为值得用Java / C#创建语言功能,以允许通过智能指针将特殊对象挂接到堆栈上。该功能将允许您将一个类标记为作用域绑定,以便始终使用与堆栈的挂钩来创建该类。可能有不同类型的智能指针的选项。

class Resource - ScopeBound
{
    /* class details */

    void Dispose()
    {
        //free resource
    }
}

void test()
{
    //class Resource was flagged as ScopeBound so the tie to the stack is implicit.
    Resource r = new Resource(); //r is a smart-pointer
    r.foo();
}//resource released on scope exit.

我认为隐式是“值得的”。正如垃圾回收的隐含性“值得”一样。显式使用块令人耳目一新,但与try-finally-dispose()相比,没有提供语义优势。

在Java / C#语言中实现这样的功能是否不切实际?可以在不破坏旧代码的情况下引入它吗?


3
这是不切实际的,这是不可能的。C#标准不保证析构函数Dispose一直运行,无论它们是如何触发的。在作用域末尾添加隐式销毁将无济于事。
Telastyn 2013年

20
@Telastyn嗯?C#标准现在所说的与您无关,因为我们正在讨论更改该文档。唯一的问题是这样做是否可行,因此,关于当前缺乏担保的唯一有趣的一点是这种缺乏担保的原因。请注意,对于using的执行是Dispose 保证的(嗯,取消进程突然死掉而不会引发异常,这时所有清理可能会变得毫无意义)。

4
的重复没有了Java的开发人员自觉放弃RAII?,尽管接受的答案是完全错误的。简短的答案是Java使用引用(堆)语义而不是值(堆栈)语义,因此确定性终结不是很有用/可能。C#确实具有值语义(struct),但除非在非常特殊的情况下,否则通常避免使用它们。另请参阅
BlueRaja-Danny Pflughoeft13年

2
这是相似的,并非完全重复。
2013年

Answers:


17

这样的语言扩展将比您想象的要复杂得多,并且更具侵入性。你不能只是添加

如果堆栈绑定类型的变量的生命周期结束,请调用Dispose它所引用的对象

并遵守语言规范的相关部分。我将忽略临时值(new Resource().doSomething())的问题,该问题可以通过更通用的措辞来解决,这不是最严重的问题。例如,此代码将被破坏(这种事情通常可能变得无法执行):

File openSavegame(string id) {
    string path = ... id ...;
    File f = new File(path);
    // do something, perhaps logging
    return f;
} // f goes out of scope, caller receives a closed file

现在,您需要用户定义的副本构造函数(或移动构造函数),并开始在各处调用它们。这不仅带来性能影响,还使这些东西有效地赋值类型,而几乎所有其他对象都是引用类型。在Java的情况下,这与对象的工作方式完全不同。在C#中,情况更是如此(已经有structs,但是没有用于它们的AFAIK的用户定义副本构造函数),但是仍然使这些RAII对象更加特殊。另外,有限类型的线性类型(请参阅Rust)也可以解决该问题,但代价是禁止包括参数传递在内的混叠(除非您希望通过采用类似Rust的借用引用和借用检查器来引入更多的复杂性)。

从技术上讲,这是可以完成的,但最终会产生一类与该语言中的其他所有内容完全不同的事情。这几乎总是一个坏主意,对实现者(更多的极端情况,每个部门更多的时间/成本)和用户(更多的要学习的概念,错误的可能性)造成后果。不值得增加额外的便利。


为什么需要复制/移动构造函数?文件仍然是引用类型。在这种情况下,作为指针的f被复制到调用方,并负责处理资源(编译器将隐式地将try-finally-dispose模式放在调用
方中

1
@bigown如果您以File这种方式对待每个引用,则不会发生任何变化并且Dispose永远不会被调用。如果您始终打电话Dispose,您将无法处理一次性物品。还是您正在提出一些方案,有时会处置而有时却不会?如果是这样,请详细描述它,我将告诉您它失败的情况。

我看不到你现在说的话(我不是说你错了)。该对象具有资源,而不是引用。
Maniero

我的理解是,将您的示例更改为仅返回值,是编译器将在资源获取之前插入一个try(在您的示例的第3行),并在作用域的结尾(第6行)之前插入finally-dispose块。没问题,同意吗?回到您的示例。编译器看到了传输,无法在此处插入try-finally,但调用者将收到一个(指向)File对象(指针),并假定调用者不再再次传输该对象,则编译器将在此处插入try-finally模式。换句话说,每个未传输的IDisposable对象都需要应用try-finally模式。
2013年

1
@bigown换句话说,Dispose如果引用转义,不要调用?转义分析是一个古老而艰巨的问题,如果不进一步更改语言,此方法将永远无法正常工作。当引用传递给另一个(虚拟)方法(something.EatFile(f);)时,应f.Dispose在作用域的末尾调用该方法吗?如果是,则中断存储的呼叫者以f备后用。如果不是,您泄漏了资源,如果主叫方存储f。消除此问题的唯一简单方法是线性类型系统,它(如我稍后在答案中已经讨论的那样)引入了许多其他复杂性。

26

为Java或C#实现类似的最大困难是定义资源传输的工作方式。您将需要某种方法来延长资源寿命,使其超出范围。考虑:

class IWrapAResource
{
    private readonly Resource resource;
    public IWrapAResource()
    {
        // Where Resource is scope bound
        Resource builder = new Resource(args, args, args);

        this.resource = builder;
    } // Uh oh, resource is destroyed
} // Crap, there's no scope for IWrapAResource we can bind to!

更糟糕的是,这对于以下实现者可能并不明显IWrapAResource

class IWrapSomething<T>
{
    private readonly T resource; // What happens if T is Resource?
    public IWrapSomething(T input)
    {
        this.resource = input;
    }
}

诸如C#的using语句之类的内容可能与您即将拥有RAII语义而无需借助引用计数资源或在C或C ++之类的任何地方强制使用值语义一样近。因为Java和C#具有由垃圾收集器管理的隐式资源共享,所以程序员所需要做的最小工作就是选择绑定资源的范围,而这正是using已经完成的工作。


假定超出范围后就不需要引用变量(并且确实不应该有此需要),我声称您仍然可以通过为其编写终结器来使对象自处置。终结器在对象被垃圾回收之前被调用。参见msdn.microsoft.com/en-us/library/0s71x931.aspx
罗伯特·哈维

8
@Robert:正确编写的程序不能假定终结器已运行。blogs.msdn.com/b/oldnewthing/archive/2010/08/09/10047586.aspx
Billy ONeal

1
嗯 好吧,这可能就是他们提出该using声明的原因。
罗伯特·哈维

2
究竟。这是C ++中大量新手错误的来源,Java / C#中也是如此。Java / C#并没有消除泄漏对将要破坏的资源的引用的能力,但是通过使之明确和可选,它们会提醒程序员,并使他有意识地选择要做什么。
2013年

1
@svick不能IWrapSomething处理T。无论T是使用usingIDisposable本身使用还是使用某些临时资源生命周期方案,创建者都需要为此担心。
2013年

13

RAII不能以C#这样的语言工作,但是可以在C ++中工作的原因是因为在C ++中,您可以确定对象是真正的临时对象(通过在堆栈上分配对象)还是寿命很长的对象(通过使用new和使用指针在堆上分配它)。

因此,在C ++中,您可以执行以下操作:

void f()
{
    Foo f1;
    Foo* f2 = new Foo();
    Foo::someStaticField = f2;

    // f1 is destroyed here, the object pointed to by f2 isn't
}

在C#中,您无法区分这两种情况,因此编译器不知道是否最终确定对象。

您可以做的是引入某种特殊的局部变量类型,您不能将其放入字段等中*,并且当其超出范围时将被自动处置。C ++ / CLI正是这样做的。在C ++ / CLI中,您可以编写如下代码:

void f()
{
    Foo f1;
    Foo^ f2 = gcnew Foo();
    Foo::someStaticField = f2;

    // f1 is disposed here, the object pointed to by f2 isn't
}

这将编译为与以下C#基本相同的IL:

void f()
{
    using (Foo f1 = new Foo())
    {
        Foo f2 = new Foo();
        Foo.someStaticField = f2;
    }
    // f1 is disposed here, the object pointed to by f2 isn't
}

总而言之,如果我猜为什么C#的设计师为什么不添加RAII,那是因为他们认为拥有两种不同类型的局部变量不值得,主要是因为在使用GC的语言中,确定性终结对经常。

* 并非没有等效的&运算符,在C ++ / CLI中为%。尽管这样做是“不安全的”,因为在方法结束后,该字段将引用已处置的对象。


1
如果C#允许struct像D这样的类型使用析构函数,那么它可以轻松执行RAII 。
Jan Hudec 2014年

6

如果让您困扰的using是它们的显式性,也许我们可以朝不那么显式性的方向迈出一小步,而不是更改C#规范本身。考虑以下代码:

public void ReadFile ()
{
  string filename = "myFile.dat";
  local Stream file = File.Open(filename);
  file.Read(blah blah blah);
}

看到local我添加的关键字了吗?它所做的就是添加更多的语法糖,就像using,告诉编译器在变量作用域的末尾调用Dispose一个finally块。就这些。它完全等同于:

public void ReadFile ()
{
  string filename = "myFile.dat";
  using (Stream file = File.Open(filename))
  {
      file.Read(blah blah blah);
  }
}

但具有隐式范围,而不是显式范围。它比其他建议更简单,因为我不必将类定义为作用域绑定。只是更干净,更隐含的语法糖。

这里可能存在难以解决的作用域问题,尽管我现在看不到它,并且感谢能找到它的人。


1
@ mike30,但是将其移至类型定义会导致您完全遇到其他问题-如果将指针传递给其他方法或从函数返回它会发生什么?这样,作用域在范围内而不是其他位置声明。一种类型可能是Disposable,但不能由它来调用Dispose。
2013年

3
@ mike30:嗯。所有这些语法所做的就是删除括号,并通过扩展删除它们提供的作用域控制。
罗伯特·哈维

1
@RobertHarvey确实如此。它牺牲了灵活性,以使代码更简洁,嵌套更少。如果我们采用@delnan的建议并重用using关键字,则在不需要特定范围的情况下,我们可以保留现有行为并也使用它。将无括号的using默认值设置为当前范围。
2013年

1
语言设计中的半练习练习没有问题。
2013年

1
@RobertHarvey。您似乎对C#中当前未实现的任何内容都有偏见。如果我们对C#1.0感到满意,我们将没有泛型,linq,使用块,ipmlicit类型等。该语法不能解决隐式问题,但可以绑定到当前作用域。
mike30

1

有关RAII如何以垃圾回收语言工作的示例,请检查withPython中关键字。不用依赖于确定性销毁的对象,而是让您将__enter__()__exit__()方法关联到给定的词法范围。一个常见的例子是:

with open('output.txt', 'w') as f:
    f.write('Hi there!')

与C ++的RAII样式一样,文件退出该块时将被关闭,无论是“正常”出口,a break,立即数return还是异常。

请注意,该open()调用是通常的文件打开功能。为此,返回的文件对象包括两个方法:

def __enter__(self):
  return self
def __exit__(self):
  self.close()

这是Python中的一个常见用法:与资源关联的对象通常包括这两种方法。

请注意,文件对象在__exit__()调用后仍可以保持分配状态,重要的是它已关闭。


7
withPython中的代码几乎与usingC#中的代码完全一样,因此就此问题而言,RAII并非如此。

1
Python的“ with”是作用域范围内的资源管理,但是它缺少智能指针的隐含性。将指针声明为智能的行为可以被认为是“显式的”,但是如果编译器将智能性作为对象类型的一部分来实施,则倾向于“隐式”。
mike30

AFAICT,RAII的重点是建立严格的资源范围。如果您只想通过释放对象来完成操作,那么不行,垃圾收集的语言无法做到这一点。如果您对持续释放资源感兴趣,那么这是一种方法(另一种是deferGo语言)。
哈维尔2013年

1
实际上,我认为可以说Java和C#强烈支持显式构造。否则,为什么要麻烦使用接口和继承所固有的所有仪式呢?
罗伯特·哈维

1
@ delnan,Go确实具有“隐式”接口。
哈维尔
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.