• Effecitve C# 原则46:最小化与其它非托管代码的交互(译)


    Effecitve C# 原则46:最小化与其它非托管代码的交互
    Item 46: Minimize Interop

    在开发设计.Net时,MS所做的最聪明的修改之一就是他们意识到,如果没有办法整合已经存在的代码到新的.Net环境中,那没没有人会接受这个新的平台。MS知道,如果没有办法来利用已经存在的代码,这将阻止大家接受它。与其它非托管代码的交互是可以工作了,但这是可交互唯一可以拿来说一下的有利的地方。对于所有的交互策略,当操作流程在本地代码和托管代码之间的边界上进行传送时,都要求强制提供一些 编组的信号。同时,交互策略强迫开发人员必须手动的申明所有调用参数(译注:这里是说你根本不知道参数的数据类型,很多时间你都只能以int32的方式传递所有参数,例如文件句柄,字符串指针等几乎是所有的参数,都只有一个int32也就是IntPtr类型进行处理,当然这里认为是是在32位机器上。)。最后,CLR还不能完成对托管代码和本地代码的边界上进行数据传递时的优化。忽略采用本地代码或者COM对象时得到的好处吧,没有什么比这更好的了(译注:我本人强烈反对这一原则。C++,COM在目前来说,绝对有它生存的优势,我觉得应该充分利用这些优势,而不应该忽略它们)。但事实是交互并不是总能工作的,很多时候我们还是在要已经存在的应用程序中添加新的功能,提高而且更新已经存在的工具,或者在其它的地方要完成一个新的托管应用程序与旧的应用程序交互。使用一些交互在实际应用中只会是减缓对旧系统的更替。所以,有明白不同的交互策略之间有什么开销是很重要的。这些开销要同时花在开发计划以及运行时性能中。有些,最后的选择是重写旧代码。还有一些时候,你须要选择正确的交互策略。

    在我讨论这个对你有用的交互策略之前,我须要花一段来讨论放弃(just throw it out)策略。第五章,与.Net框架一起工作,向你展示了一些.Net里已经为你创建好了的类和技术,你可以直接使用或者派生。为你你想的很多,你可以确定一些类和你一些代码算法,并且全部用C#重写。剩下存在的代码可以被.Net框架里已经存在的可能功能性的派生来取代。这并不会总是在任何地方,任何时候都可以工作的,但这确实是一个经过认真考虑过的迁移策略。整个第5章都推荐使用"throw it out“策略。这一原则就专注于交互,而它确实是件痛苦的事情。

    接下来,让我们假设你已经决定重写全部代码并不实际。一些不同的策略要求你从.Net中访问本地代码。你须要明白在本地代码和托管代码的边界上传递数据时的开销的低效。在使用交互时有三个开销。首先就是数据集群处理,这在托管堆和本地代码堆之间进行数据传递时发生。其次就是在托管代码和非托管代码进行交互时的大量数据吞吐时的开销。你以及你的用户要承担这些开销。第三个开销就只是你自己的了:你要在这个混合的开发环境中添加很多工作来实现交互。这也是最糟糕的一个,所以你的设计应该决定最小化这样的开销。

    让我们开始讨论交互时在性能上的开销,以及如何最小化这些开销。数据集群是最大的一个因数,就像是网络服务或者远程操作一样,你须要尽可能使用笨重的(chunky)API而不是小巧的(chatty )API(译注:数据集群是指你没有办法即时的与本地代码进行交互,而有一个延时,这个延时就使用数据堆集起来一起处理,这样就使得你应该尽可能少的与本地代码进行交互,而要选择一些一次可以处理较多数据的API)。你可以用不同的方法来完成与非托管代码的交互。你可以重新修改已经存在的非托管代码来创建一个笨重的API,更适合交互的API。常规的COM应用中是申明很多属性,这样客户可以设置并修改COM对象内部的状态或者行为。每次的设置属性都会集群数据,而且不得不穿越边界。(而且每在穿越交互边界时也会有thunks。)这非常的低效,不幸的是,COM对象或者非托管库可能不受你控制。当这种情况发生时,你须要完成更麻烦的工作。这时,你可以创建一个轻量级的C++库,通过使用你所须要的chunkier API来暴露类型的功能。这就要增加你的开发时间了(也就是第三个开销)。

    当你封装一个COM对象时,确保你修改的数据类型已经在本地代码一托管代码之间提供了最好的数据集群策略。有些类型可以很好的比其它类型进行集群,试着限制用于在本地代码和托管代码之间进行传递的数据类型,尽量使用blittable数据。blittable是指托管代码和本地代码都一样使用的类型。数据内容可以直接拷贝而不用管对象的内部的结构。某些情况下,非托管代码可能使用托管代码的代码。下面列出了blittable 类型:

    System.Byte
    System.SByte
    System.Int16
    System.UInt16
    System.Int32
    System.UInt32
    System.Int64
    System.UInt64
    System.UIntPtr

    另外,任何的blittable类型的一维数组也是blittable类型。最后,任何格式化的包含blittable类型的也是blittable类型。一个格式化的类型可以是一个用StructLayoutAttribute明确定义了数据层次的结构,

    [ StructLayout( LayoutKind.Sequential ) ]
    public struct Point3D
    {
      public int X;
      public int Y;
      public int Z;
    }


    当你在托管代码和非托管代码之间,只使用blittable 类型时,你就最小化了多少必须拷贝的信息呀!你同样也优化了任何必须发生的拷贝操作。

    如果在数据拷贝时,你不能限制数据类型让它成为blittable 类型,你可以使用InAttribute 和OutAttribute 来进行控制。也COM类似,这些特性控制数据拷贝的方法。In/Out 参数双向拷贝,In参数以及Out参数是一次拷贝。确保你应用严格限制的In/Out组合,来避免更多不必须拷贝。

    最后,你可以通过申明如何集群数据来提高性能。对于字符串来说这是最常见的。集群字符串默认是使用BSTRs。这是一个安全的策略,但这也是最低效的。你可以通过修改默认的集群格式减少额外的拷贝操作,可以使用MarshalAs 特性来修改集群方式。下面申明了字符串的集群使用LPWStr或者wchar*:


    public void SetMsg(
      [ MarshalAs( UnmanagedType.LPWStr ) ] string msg );

    这有一个关于处理托管和非托管层上数据的轶事:数据被拷贝然后在托管和非托管类型之间进行传输。你有三个方法业最小化拷贝操作。首先就是通过限制参数和返回值,让它们是blittable类型。这应该是首选的。当你不能这样做时,应用In和Out特性来最小化必须完成的拷贝和传输操作。最后一个优化就是,很多类型可以不只一种集群方式,你应该选择最优化的一种。

    现在让我们转到如何在托管的非托管组件中转移程序控制。你有三种选择:COM交互,平台调用(P/Invoke),以及托管C++。每种方法有它自己的优势和劣势。

    COM交互是最简单的方法来使用已经存在的COM组件。但COM交互也是在.Net中和本地代码交互时最低效的一种方式。除非你的COM组件已经有很重要的利益,否则不要这样做。不要看也不要想这种方式。如果你没有COM组件而要使用这种方法就意味着你要把COM和交互原则学的一样好。没时间让你开始理解IUnknown(译注:COM原理中最基本的接口,任何COM都实现了这样的接口)。那些这样做的人都试着从我们的内存中尽快的清理它们。使用COM交互同样意味着在运行时你要为COM子系统承担开销。你同样还要考虑,在不同的CLR对象的生命期管理和COM版本的对象生命期管理之间又意味看什么。你可以服从CLR的原则,这就是说做一个你所导入的COM对象有一个析构函数,也就是在COM接口上调用的Release()。或者你可以自己清楚的使用ReleaseCOMObject()来释放COM对象。第一种方法会给应用程序引入运行时的低效(参见原则15)。而第二个在你的程序里又是头疼的事。使用ReleaseCOMObject ()就意味看你要深入到管理细节上,而这些CLR的COM交互已经帮我们完成了。你已经了解了,而且你认你明白最好的。CLR请求所有不同,而且它要释放COM对象,除非你正确的告诉它,你已经完成了。这是一个极大的鬼计,因为COM专家程序员都是在每个接口上调用Release(),而你的托管代码是以对象处理的。简单的说,你必须知道什么接口已经在对象上添加了AddRef,而且只释放这些(译注:COM的引用非常严格,每个引用都会添加一个AddRef,释放时必须明确的给出Release(),而且必须成对使用,而在.Net对COM进行封装后,很多时候就是这个引用不匹配而出现资源泄漏)。就让CLR来管理COM的生命期,你来承担性能损失就行了。你是一个烦忙的开发人员,想在.Net中混合COM资源到托管环境里,你要学习的太多了(也就是第三个开销)。
    第二个选择是使用P/Invoke。这是一个更高效的方法来调用Win32的API,因为你避免了在上层与COM打交道。而坏消息是,你须要为你使用的每个P/Invoke方法手写这些接口。越是调用多的方法,越是多的申明必须手写。这种P/Invoke申明就是告诉CRL如何访问本地方法。这里额外的解释一下为什么每个一个关于P/Invoke的例子里(也包括下面这个)都使用MessageBox:

    public class PInvokeMsgBox
    {
       [ DllImport( "user32.dll" ) ]
       public static extern int MessageBoxA(
          int h, string m, string c, int type );

       public static int Main()
       {
          return MessageBoxA( 0,
            "P/InvokeTest",
            "It is using Interop", 0 );
       }
    }

    另一个使用P/Invoke的主要缺点是,这并不是设计成面向对象的语言。如果你须要导入C++库,你必须在你的导入申明中指明封装名。假设取代Win32的MessageBox API,你想访问MFC的C++DLL里的另外两个AfxMessageBox 方法。你须要为其中一个方法创建一个P/Invoke申明:

    ?AfxMessageBox@@YGHIII@Z
    ?AfxMessageBox@@YGHPBDII@Z

    这两个申明名是与下面的两个方法匹配的:


    int AfxMessageBox( LPCTSTR lpszText,
      UINT nType, UINT nIDHelp );
    int AFXAPI AfxMessageBox( UINT nIDPrompt,
      UINT nType, UINT nIDHelp);

    即使是在重写少数几个方法之后,你很快就明白这不是一个高产的方法来提供交互功能。简单的说,使用P/Invoke只能访问C风格的Win32方法(在开发上要开销更多的时间)。

    最后一种选择就是在Microsoft C++编译器上使用/CLR开关来混合托管的非托管代码。如果你编译你所有的本地代码使用/CLR,你就创建了一个基于MSIL的库,该库使用本地堆来存储所有的数据。这就是说,这样的C++库不能直接被C#调用。你必须在你所熟悉的代码上创建一个托管的C++库,用于在托管和非托管类型之间创建一个桥梁,提供在托管和非托管的堆之间的数据集群支持。这样的C++库包含托管类,这些数据成员是基于托管堆的。这些类同样包含对本地对象的引用:


    // Declare the managed class:
    public __gc class ManagedWrapper : public IDisposable
    {
    private:
      NativeType* _pMyClass;

    public:
      ManagedWrapper( ) :
        _pMyClass( new NativeType( ) )
      {
      }
      // Dispose:
      virtual void Dispose( )
      {
        delete _pMyClass;
        _pMyClass = NULL;
        GC::SuppressFinalize( this );
      }

      ~ManagedWrapper( )
      {
        delete _pMyClass;
      }

      // example property:
      __property System::String* get_Name( )
      {
        return _pMyClass->Name( );
      }
      __property void set_Name( System::String* value )
      {
        char* tmp  = new char [ value->Length + 1 ];
        for (int i = 0 ; i < value->Length; i++ )
          tmp[ i ] = ( char )value->Chars[ i ];
        tmp[ i ] = 0;
        _pMyClass->Name( tmp );
        delete [] tmp;
      }

      // example method:
      void DoStuff( )
      {
        _pMyClass->DoStuff( );
      }

      // other methods elided...
    }


    再说一次,这并不是一个像以前使用过的高产的程序开发工具。这只是代码重复,而且整个目的都要在托管和非托管数据之间进行集群数据和thunking。优势是,你可以从你的本地代码中暴露出来的方法和属性上完成控制。而劣势是你必须手写一部份.Net代码以及一部份C++代码。在这两种代码之间转换很容易让你发生错误。你还不能忘记删除非托管对象。托管对象不是你 要负责的。这会让你的开发进度降下来,因为要不断的检测这是否正确。

    使用 /CLR 开关听上去像是一个神话,但对于所有的交互来说,这并不是一个神弹(magic bullet)。C++里的模板和异常处理与C#是大不相同的。写的很好很高效的C++并不一写能转化成最好的MSIL结构。更重要是,编译C++代码时的 /CLR开关并不做确认。正如我前面所说的,这样的代码是使用本地堆:它访问本地内存。而CLR并不能验证这些代码是安全的。调用这些代码的程序必须确保有安全许可来访问不安全代码。虽然如此,/CLR策略还是最好的一个方法,在.Net中利用已经存在的C++代码(不是COM对象)。你的程序不会招致thunking开销,而为你的C++库现在并不在MSIL中,MSIL不是本地的CPU指令。

    交互操作是件头疼的事。在你使用交互之间,认真的考虑写本地应用程序。这经常是简单而且很快的。不幸的是,对于很多的开发人员来说,交互是必须的。如果你有已经存在的用其它语言写的COM对象,使用COM交互。如果你有已经存在的C++代码,使用 /CLR 开关并托管C++来提供最好的策略来方法已经存在的本地代码。选择最省时间的一个策略,这可能就是“just thro it out”策略。

    ===================
       

    Item 46: Minimize Interop
    One of the smartest moves Microsoft made when designing .NET was to realize that no one would adopt the platform if there wasn't a way to integrate their existing code assets into new .NET development. Microsoft knew that without a way to leverage existing code, adoption would slow down. But that doesn't make interop easy or efficient. Interop works, but that's the only good thing that can be said about it. All the interop strategies are forced to provide some marshalling when the flow of control passes between the native and the managed boundaries. Also, interop strategies force you, the developer, to declare the method parameters by hand. Finally, the CLR cannot perform optimizations across an interop boundary. Nothing would be better for a developer than to ignore all the investment in native code and COM objects. But the world doesn't always work that way. Most of us need to add new features to existing applications, enhance and update existing tools, or otherwise make new managed applications interact with old legacy applications. Using some kind of interop is often the only practical way to slowly replace legacy systems. Therefore, it's important to understand the costs associated with the different interop strategies. These costs are paid in terms of both development schedules and runtime performance. Sometimes, the best choice is to rewrite the legacy code. Other times, you need to pick the correct interop strategy.

    Before I discuss the interop strategies that are available to you, I need to spend a paragraph discussing the "just throw it out" strategy. Chapter 5, "Working with the Framework," showed you some of the classes and techniques that are already built for you and delivered in the .NET Framework. More often than you would think, you can identify the classes and algorithms that represent your core functionality and rewrite only those in C#. The rest of the existing codebase can be replaced by the functionality delivered in the .NET Framework. It doesn't work everywhere or every time, but it should be seriously considered as a migration strategy. All of Chapter 5 could be taken as a recommendation to follow the "throw it out" strategy. This one item is dedicated to interop. Interop is painful.

    For the rest of this item, let's assume that you've determined that the full rewrite isn't practical. Several different strategies will let you access native code from .NET code. You need to understand the cost and inefficiencies inherent in crossing the boundary between managed and unmanaged code. There are three tolls to pay using interop. The first toll is paid by marshalling data back and forth between the managed heap and the native heap. The second toll is the thunking cost of moving between managed code and unmanaged code. You and your users pay these performance tolls. The third toll is yours alone: the amount of work you need to perform to manage this mixed environment. The third toll is the biggest, so your design decisions should minimize that cost.

    Let's begin by discussing the performance costs associated with interop and how to minimize that cost. Marshalling is the single biggest factor. As with the web services and remoting, you need to strive for a chunky API rather than a chatty API. You accomplish this differently when you interact with unmanaged code. You create a chunky interop API by modifying the existing unmanaged to add a new, more interop-friendly API. A common COM practice is to declare many properties that clients can set, changing the internal state or the behavior of the object. Setting each property marshals data back and forth across the boundary. (It also thunks each time as it crosses the interop boundary.) That is very inefficient. Unfortunately, the COM object or unmanaged library might not be under your control. When that happens, you need to work harder. In this case, you can create a very thin unmanaged C++ library that exposes the type's capabilities using the chunkier API that you need. That's going to increase your development time (that third toll again).

    When you wrap a COM object, make sure that you modify the data types to provide a better marshalling strategy between the managed and unmanaged sections of your code. Some types can be marshaled much more efficiently than others. Try to limit the data types passed between the managed and unmanaged layers of your code to blittable types. A blittable type is one in which the managed and unmanaged representations of the type are the same. The contents can be copied without regard to the internal structure of the object. In some cases, the unmanaged code can use the managed memory. The blittable types are listed here:

    System.Byte
    System.SByte
    System.Int16
    System.UInt16
    System.Int32
    System.UInt32
    System.Int64
    System.UInt64
    System.UIntPtr

    In addition, any one-dimensional array of a blittable type is blittable. Finally, any formatted type that contains only blittable types is blittable. A formatted type is a struct that explicitly defines its layout using StructLayoutAttribute:

    [ StructLayout( LayoutKind.Sequential ) ]
    public struct Point3D
    {
      public int X;
      public int Y;
      public int Z;
    }

    When you use only blittable types between the unmanaged and managed layers of your code, you minimize how much information must be copied. You also optimize any copy operations that must occur.

    If you can't restrict your data types to the blittable types, you can use InAttribute and OutAttribute to control when copies are made. Similar to COM, these attributes control which direction the data is copied. In/Out parameters are copied both ways; In parameters and Out parameters are copied only once. Make sure you apply the most restrictive In/Out combination to avoid more copying than necessary.

    Finally, you can increase performance by declaring how data should be marshaled. This is most common with strings. Marshalling strings uses BSTRs by default. That's a safe strategy, but it is the least efficient. You can save extra copying operations by modifying the default marshalling scheme by applying the MarshalAs attribute. The following declaration marshals the string as a LPWStr, or wchar*:

    public void SetMsg(
      [ MarshalAs( UnmanagedType.LPWStr ) ] string msg );

    That's the short story for handling data between managed and unmanaged layers: Data gets copied and possibly translated between managed and unmanaged types. You can minimize the copy operations in three ways. The first is by limiting the parameters and return values to blittable types. That's the preferred solution. When you can't do that, apply the In and Out attributes to minimize the copy and transfer operations that must occur. As a final optimization, some types can be marshaled in more than one manner, so pick the most optimal manner for your use.

    Now let's move on to how you can transfer program control between managed and unmanaged components. You have three options: COM interop, Platform Invoke (P/Invoke), and managed C++. Each has its own advantages and disadvantages.

    COM interop is the easiest way to leverage those COM components you are already using. But COM interop is the least efficient way to access native code in .NET. Unless you already have a significant investment in COM components, don't go down this path. Don't look at this pathdon't even think about it. Using COM interop if you don't have COM components means learning COM as well as all the interop rules. This is no time to start understanding IUnknown. Those of us who did are trying to purge it from our memories as quickly as possible. Using COM interop also means that you pay the runtime cost associated with the COM subsystem. You also have to consider what it means in terms of the differences between the CLR's object lifetime management and the COM version of object lifetime management. You can defer to the CLR, in which case every COM object you import has a finalizer, which calls Release() on that COM interface. Or, you can explicitly release the COM object yourself by using ReleaseCOMObject. The first approach introduces runtime inefficiencies in your program (see Item 15). The second introduces headaches in your programmers. Using ReleaseCOMObject means you are diving down into the management issues already solved by the CLR's COM interop layer. You're taking over, and you think you know best. The CLR begs to differ, and it releases COM objects, unless you tell it correctly that you have done so. This is tricky, at best, because COM expects programmers to call Release() on each interface, and your managed code is dealing with objects. In short, you need to know which interfaces have been AddRef'd on an object and release only those. Let the CLR manage COM lifetimes for you, and pay the performance costs. You're a busy developer. Learning to mix COM resource management in .NET is more than you should take on (that third toll).

    Your second option is to use P/Invoke. This is the most efficient way to call any of the Win32 APIs because you avoid the overhead associated with COM. The bad news is that you need to hand-code the interface to each method that you call using P/Invoke. The more methods you invoke, the more method declarations you must hand-code. This P/Invoke declaration tells the CLR how to access the native method. This extra work explains why every example of P/Invoke (including the following one) uses MessageBox:

    public class PInvokeMsgBox
    {
       [ DllImport( "user32.dll" ) ]
       public static extern int MessageBoxA(
          int h, string m, string c, int type );

       public static int Main()
       {
          return MessageBoxA( 0,
            "P/InvokeTest",
            "It is using Interop", 0 );
       }
    }

    The other major drawback to P/Invoke is that it is not designed for object-oriented languages. If you need to import a C++ library, you must specify the decorated names in your import declarations. Suppose that instead of the Win32 MessageBox API, you wanted to access one of the two AfxMessageBox methods in the MFC C++ DLL. You'd need to create a P/Invoke declaration for one of these two methods:

    ?AfxMessageBox@@YGHIII@Z
    ?AfxMessageBox@@YGHPBDII@Z

    These two decorated names match these two methods:

    int AfxMessageBox( LPCTSTR lpszText,
      UINT nType, UINT nIDHelp );
    int AFXAPI AfxMessageBox( UINT nIDPrompt,
      UINT nType, UINT nIDHelp);

    Even after just a few overloaded methods, you quickly realize that this is not a productive way to provide interoperability. In short, use P/Invoke only to access C-style Win32 methods (more toll in developer time).

    Your last option is to mix managed and unmanaged code using the /CLR switch on the Microsoft C++ compiler. If you compile all your native code using /CLR, you create an MSIL-based library that uses the native heap for all data storage. That means this C++ library cannot be called directly from C#. You must build a managed C++ library on top of your legacy code to provide the bridge between the unmanaged and managed types, providing the marshalling support between the managed and unmanaged heaps. This managed C++ library contains managed classes, whose data members are on the managed heap. These classes also contain references to the native objects:

    // Declare the managed class:
    public __gc class ManagedWrapper : public IDisposable
    {
    private:
      NativeType* _pMyClass;

    public:
      ManagedWrapper( ) :
        _pMyClass( new NativeType( ) )
      {
      }
      // Dispose:
      virtual void Dispose( )
      {
        delete _pMyClass;
        _pMyClass = NULL;
        GC::SuppressFinalize( this );
      }

      ~ManagedWrapper( )
      {
        delete _pMyClass;
      }

      // example property:
      __property System::String* get_Name( )
      {
        return _pMyClass->Name( );
      }
      __property void set_Name( System::String* value )
      {
        char* tmp  = new char [ value->Length + 1 ];
        for (int i = 0 ; i < value->Length; i++ )
          tmp[ i ] = ( char )value->Chars[ i ];
        tmp[ i ] = 0;
        _pMyClass->Name( tmp );
        delete [] tmp;
      }

      // example method:
      void DoStuff( )
      {
        _pMyClass->DoStuff( );
      }

      // other methods elided...
    }

    Again, this is not the most productive programming tool we've ever used. This is repetitive code, and the entire purpose is to handle the marshalling and thunking between managed and unmanaged data. The advantages are that you have complete control over how you expose your methods and properties from your native code. The disadvantage is that you have to write all this code with one part of your head writing .NET code and another part writing C++. It's easy to make simple mistakes as you shift between the two. You must remember to delete unmanaged objects. Managed objects are not your responsibility. It slows down your developer time to constantly check which is correct.

    Using the /CLR switch sounds like magic, but it's not the magic bullet for all interop scenarios. Templates and exception handling are handled quite differently in C++ and C#. Well-written, efficient C++ does not necessarily translate into the best MSIL constructs. More importantly, C++ code compiled with the /CLR switch is not verifiable. As I said earlier, this code uses the native heap: It accesses native memory. The CLR cannot verify this code as safe. Programs that call this code must have been granted security permission to access unsafe code. Even so, the /CLR strategy is the best way to leverage your existing C++ code (not COM objects) in .NET. Your program does not incur the thunking cost because your C++ libraries are now in MSIL, not native CPU instructions.

    Interop is painful. Seriously consider rewriting native applications before you use interop. It is often easier and faster. Unfortunately for many developers, interop is necessary. If you have existing COM objects written in any language, use COM interop. If you have existing C++ code, the /CLR switch and managed C++ provide the best strategy to access your existing native codebase from new development created in C#. Pick the strategy that takes the least time. It might be the "just throw it out" strategy.

  • 相关阅读:
    使用isinstance()函数,判断输入的函数是否为已知的类型。raise() 引发异常。
    maya命令的运用。范例1。#举一反三
    maya中英文切换
    变量和maya命令 #getAttr #setAttr #xform #connectAttr #disconnectAttr
    变量和maya命令 #polySphere命令,创建、查询、编辑。
    PyCharm配置Maya开发环境
    希望与憧憬
    Java RandomAccessFile基本的用法
    Java 文件(File)类
    计算机编码问题
  • 原文地址:https://www.cnblogs.com/WuCountry/p/701947.html
Copyright © 2020-2023  润新知