• 重新开始学习javase_一切都是对象


    @学习thinking in java

    一,一切都是对象

    1. 用句柄操纵对象
        • 每种编程语言都有自己的数据处理方式。比如说c与c++中的指针,而java中尽管将一切都“看作”对象,但操纵的标识符实际是指向一个对象的“句柄”(Handle)。在其他 Java 参考书里,还可看到有的人将其称作一个“引用”,甚至一个“指针”。可将这一情形想象成用遥控板(句柄)操纵电视机(对象)。只要握住这个遥控板,就相当于掌握了与电视机连接的通道。但一旦需要“换频道”或者“关小声音”,我们实际操纵的是遥控板(句柄),再由遥控板自己操纵电视机(对象)。如果要在房间里四处走走,并想保持对电视机的控制,那么手上拿着的是遥控板,而非电视机。此外,即使没有电视机,遥控板亦可独立存在。也就是说,只是由于拥有一个句柄,并不表示必须有一个对象同它连接。所以如果想容纳一个词或句子,可创建一个 String句柄:String s;








    2. 所有对象都必须创建
      1.   也就是说当你创建了句柄时,需要把它与一个具体的对象连接起来


      2.   java中数据的保存位置
        1. 栈:每当启用一个线程时,JVM就为他分配一个Java栈,栈是以帧为单位保存当前线程的运行状态。每当线程调用一个Java方法时,JVM就会在该线程对应的栈中压入一个帧,这个帧自然就成了当前帧。当执行这个方法时,它使用这个帧来存储参数、局部变量、中间运算结果等等。Java栈上的所有数据都是私有的。任何线程都不能访问另一个线程的栈数据。所以我们不用考虑多线程情况下栈数据访问同步的情况。
          •   栈帧:

            栈帧由三部分组成:局部变量区、操作数栈、帧数据区。局部变量区和操作数栈的大小要视对应的方法而定,他们是按字长计算的。但调用一个方法时,它从类型信息中得到此方法局部变量区和操作数栈大小,并据此分配栈内存,然后压入Java栈。

            局部变量区 局部变量区被组织为以一个字长为单位、从0开始计数的数组,类型为short、byte和char的值在存入数组前要被转换成int值,而long和double在数组中占据连续的两项,在访问局部变量中的long或double时,只需取出连续两项的第一项的索引值即可,如某个long值在局部变量区中占据的索引时3、4项,取值时,指令只需取索引为3的long值即可。

            下面就看个例子,好让大家对局部变量区有更深刻的认识。这个图来自《深入JVM》:

            1. public static int runClassMethod(int i,long l,float f,double d,Object o,byte b) {     
            2. return 0;     
            3.     }     
            4. public int runInstanceMethod(char c,double d,short s,boolean b) {     
            5. return 0;     
            6.     }    

            上面代码片的方法参数和局部变量在局部变量区中的存储结构如下图:

            局部变量区的存储结构

            上面这个图没什么好说的,大家看看就会懂。但是,在这个图里,有一点需要注意:

            runInstanceMethod的局部变量区第一项是个reference(引用),它指定的就是对象本身的引用,也就是我们常用的this,但是在runClassMethod方法中,没这个引用,那是因为runClassMethod是个静态方法。

            操作数栈和局部变量区一样,操作数栈也被组织成一个以字长为单位的数组。但和前者不同的是,它不是通过索引来访问的,而是通过入栈和出栈来访问的。可把操作数栈理解为存储计算时,临时数据的存储区域。下面我们通过一段简短的程序片段外加一幅图片来了解下操作数栈的作用。

            int a = 100;

            int b = 98;

            int c = a+b;

            字节码序列:

            iload_0 //入栈

            iload_1 //入栈

            iadd //弹出2个栈中数据相加后,把结果入栈

            istore_2 //弹出结果存储到局部变量中位置2处

            操作数栈的结构

            从图中可以得出:操作数栈其实就是个临时数据存储区域,它是通过入栈和出栈来进行操作的。

            帧数据区除了局部变量区和操作数栈外,Java栈帧还需要一些数据来支持常量池解析、正常方法返回以及异常派发机制。这些数据都保存在Java栈帧的帧数据区中。
            当JVM执行到需要常量池数据的指令时,它都会通过帧数据区中指向常量池的指针来访问它。

            除了处理常量池解析外,帧里的数据还要处理Java方法的正常结束和异常终止。如果是通过return正常结束,则当前栈帧从Java栈中弹出,恢复发起调用的方法的栈。如果方法又返回值,JVM会把返回值压入到发起调用方法的操作数栈。

            为了处理Java方法中的异常情况,帧数据区还必须保存一个对此方法异常引用表的引用。当异常抛出时,JVM给catch块中的代码。如果没发现,方法立即终止,然后JVM用帧区数据的信息恢复发起调用的方法的帧。然后再发起调用方法的上下文重新抛出同样的异常。

          • 栈的整个结构
            1. 栈的整个结构

              在前面就描述过:栈是由栈帧组成,每当线程调用一个Java方法时,JVM就会在该线程对应的栈中压入一个帧,而帧是由局部变量区、操作数栈和帧数据区组成。那在一个代码块中,栈到底是什么形式呢?下面是我从《深入JVM》中摘抄的一个例子,大家可以看看:

              代码片段:

              栈的整个结构代码示例

              执行过程中的三个快照:

              上面所给的图,只想说明两件事情,我们也可用此来理解Java中的栈:

              1、只有在调用一个方法时,才为当前栈分配一个帧,然后将该帧压入栈。

              2、帧中存储了对应方法的局部数据,方法执行完,对应的帧则从栈中弹出,并把返回结果存储在调用方法的帧的操作数栈中。

          • stackoverflowererror
            1.   知道了栈的结构,现在可以用程序跑出这个错误了
              @Test
                  public void test03() {
                      printI();
                  }
                  
                  public void printI(){
                      printI();
                  }

               运行结果:



              为什么会发生这样的错误 已经很简单了吧---无限调用方法,每个方法需要一个栈帧,所以栈就满了。。。所以报错

        2. 堆:一种常规用途的内存池(也在 RAM区域),其中保存了Java 对象。和堆栈不同,“内存堆”或
          “堆”(Heap)最吸引人的地方在于编译器不必知道要从堆里分配多少存储空间,也不必知道存储的数据要
          在堆里停留多长的时间。因此,用堆保存数据时会得到更大的灵活性。要求创建一个对象时,只需用new命
          令编制相关的代码即可。执行这些代码时,会在堆里自动进行数据的保存。当然,为达到这种灵活性,必然
          会付出一定的代价:在堆里分配存储空间时会花掉更长的时间!java堆是虚拟机所管理 的内存中最大的一块。java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域唯一的目的就是存放对象的实例,这一点在java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。但并不是那么绝对
          •   outofmemoryerror:很简单
            @Test
                public void test04() {
                    int[] i=new int[100000000];
                }

        3. 方法区:与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据,虽然java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做非堆,目的应该是与java堆区分开。

        4. 运行时常量池:是方法区的一部分,class文件中除了有类的板本,字段,方法,接口等描述跾外,还有一项信息是常量池,用于存放编译期生成 的各种字面量和符号引用,这部分内部将在类加载后进入方法区的运行时常量池中存放

          • 有三个概念需要清楚:
            1. 常量池(Constant Pool):常量池数据编译期被确定,是Class文件中的一部分。存储了类、方法、接口等中的常量,当然也包括字符串常量。

            2. 字符串池/字符串常量池(String Pool/String Constant Pool):是常量池中的一部分,存储编译期类中产生的字符串类型数据。

            3. 运行时常量池(Runtime Constant Pool):方法区的一部分,所有线程共享。虚拟机加载Class后把常量池中的数据放入到运行时常量池。



      3.    基本类型:不是用new创建变量,而是创建一个并非句柄的“自动”变量。这个变量容纳了具体的值,并置于堆栈中,能够更高效地存取。
        •   主类型   大小   最小值   最大值   封装器类型

            boolean 1 位 - - Boolean
            char 16位 Unicode 0 Unicode 2的 16次方-1 Character
            byte 8位 -128 +127 Byte
            short 16 位 -2 的15 次方 +2的 15次方-1 Short
            int 32位 -2的 31次方 +2 的31 次方-1 Integer
            long 64位 -2 的63 次方 +2的 63次方-1 Long
            float 32 位 IEEE754 IEEE754 Float
            double 64 位 IEEE754 IEEE754 Double

        • 高精度数字
          • Java 1.1 增加了两个类,用于进行高精度的计算:BigInteger和 BigDecimal。尽管它们大致可以划分为“封装器”类型,但两者都没有对应的“主类型”。这两个类都有自己特殊的“方法”,对应于我们针对主类型执行的操作。也就是说,能对int或 float做的事情,对BigInteger 和BigDecimal 一样可以做。只是必须使用方法调用,不能使用运算符。此外,由于牵涉更多,所以运算速度会慢一些。我们牺牲了速度,但换来了精度。
            BigInteger支持任意精度的整数。也就是说,我们可精确表示任意大小的整数值,同时在运算过程中不会丢失任何信息。
            BigDecimal支持任意精度的定点数字。例如,可用它进行精确的币值计算。至于调用这两个类时可选用的构建器和方法


      4.     数组

        •   Java 的一项主要设计目标就是安全性。所以在C 和 C++里困扰程序员的许多问题都未在Java 里重复。一个Java 可以保证被初始化,而且不可在它的范围之外访问。由于系统自动进行范围检查,所以必然要付出一些代价:针对每个数组,以及在运行期间对索引的校验,都会造成少量的内存开销。但由此换回的是更高的安全性,以及更高的工作效率。为此付出少许代价是值得的。创建对象数组时,实际创建的是一个句柄数组。而且每个句柄都会自动初始化成一个特殊值,并带有自己的关键字:null(空)。一旦 Java 看到null,就知道该句柄并未指向一个对象。正式使用前,必须为每个句柄都分配一个对象。若试图使用依然为null 的一个句柄,就会在运行期报告问题。因此,典型的数组错误在Java 里就得到了避免。也可以创建主类型数组。同样地,编译器能够担保对它的初始化,因为会将那个数组的内存划分成零。








    3.    对象的存在时间

      •  基本类型的存在时间----其实简单的说就是在{}中定义的变量,只能在该{}中使用
        例:

      •  对象的存在时间
        我们知道java中new一个对象出来所存放的位置是在堆中,是共享的。其实当我们在{}中Student a=new Student()后,离开了{}之后我们这个对象的句柄s不再指向我们new出来的Stduent了,但这不意味着对象的存在范围就和基本类型一样,其实这个对象还是存在堆中的,只不过不能再访问它了,等后java虚拟机的回收了


    4.   新建数据类型:类
      一切事务都是对象,类(class)是描述对象的外观与形为的
      •   定义一个类时(我们在 Java 里的全部工作就是定义类、制作那些类的对象以及将消息发给那些对象),可在自己的类里设置两种类型的元素:数据成员(有时也叫“字段”,更多是成员变量,成员属性)以及成员函数(通常叫“方法”)。其中,数据成员是一种对象(通过它的句柄与其通信),可以为任何类型。它也可以是主类型(并不是句柄)之一。如果是指向对象的一个句柄,则必须初始化那个句柄,用一种名为“构建器”的特殊函数将其与一个实际对象连接起来(就象早先看到的那样,使用new关键字)。但若是一种主类型,则可在类定义位置直接初始化(正如后面会看到的那样,句柄亦可在定义位置初始化)。每个对象都为自己的数据成员保有存储空间;数据成员不会在对象之间共享。下面是定义了一些数据成员的类示例:

        class DataOnly {
          int i;
             float f;
             boolean b;
        }
        这个类并没有做任何实质性的事情,但我们可创建一个对象:
        DataOnly d = new DataOnly();
        可将值赋给数据成员,但首先必须知道如何引用一个对象的成员。为达到引用对象成员的目的,首先要写上
        对象句柄的名字,再跟随一个点号(句点),再跟随对象内部成员的名字。即“对象句柄.成员”。例如:
        d.i = 47;
        d.f = 1.1f;
        d.b = false;
        一个对象也可能包含了另一个对象,而另一个对象里则包含了我们想修改的数据。对于这个问题,只需保持
        “连接句点”即可。例如:
        myPlane.leftTank.capacity = 100;
        除容纳数据之外,DataOnly 类再也不能做更多的事情,因为它没有成员函数(方法)。为正确理解工作原
        理,首先必须知道“自变量”和“返回值”的概念。我们马上就会详加解释。

        • 成员属性如是是基本类型的话,会有对应的默认值(局部变量没有此功能,编译时会检查)

          主类型 默认值
          Boolean false
          Char 'u0000'(null)
          byte (byte)0
          short (short)0
          int 0
          long 0L
          float 0.0f
          double 0.0d







    5.    方法、自变量和返回值 

      ava 的“方法”决定了一个对象能够接收的消息。通过本节的学习,大家会知道方法的定义有多么简单!
      方法的基本组成部分包括名字、自变量、返回类型以及主体。下面便是它最基本的形式:

      返回类型 方法名( /* 自变量列表*/ ) {/* 方法主体 */}

      返回类型是指调用方法之后返回的数值类型。显然,方法名的作用是对具体的方法进行标识和引用。自变量
      列表列出了想传递给方法的信息类型和名称。

      Java 的方法只能作为类的一部分创建。只能针对某个对象调用一个方法(注释③),而且那个对象必须能够
      执行那个方法调用。若试图为一个对象调用错误的方法,就会在编译期得到一条出错消息。为一个对象调用
      方法时,需要先列出对象的名字,在后面跟上一个句点,再跟上方法名以及它的参数列表。亦即“对象名.方
      法名(自变量1,自变量2,自变量 3...)。举个例子来说,假设我们有一个方法名叫 f(),它没有自变量,返
      回的是类型为int的一个值。那么,假设有一个名为 a 的对象,可为其调用方法f(),则代码如下:
      int x = a.f();
      返回值的类型必须兼容 x的类型。

      象这样调用一个方法的行动通常叫作“向对象发送一条消息”。在上面的例子中,消息是f(),而对象是 a。
      面向对象的程序设计通常简单地归纳为“向对象发送消息”。

      ③:正如马上就要学到的那样,“静态”方法可针对类调用,毋需一个对象。


      •   自变量列表

        自变量列表规定了我们传送给方法的是什么信息。正如大家或许已猜到的那样,这些信息——如同Java 内其
        他任何东西——采用的都是对象的形式。因此,我们必须在自变量列表里指定要传递的对象类型,以及每个
        对象的名字。正如在Java 其他地方处理对象时一样,我们实际传递的是“句柄”(注释④)。然而,句柄的
        类型必须正确。倘若希望自变量是一个“字串”,那么传递的必须是一个字串。

        ④:对于前面提及的“特殊”数据类型 boolean,char,byte,short,int,long,,float 以及double 来
        说是一个例外。但在传递对象时,通常都是指传递指向对象的句柄。

        下面让我们考虑将一个字串作为自变量使用的方法。下面列出的是定义代码,必须将它置于一个类定义里,
        否则无法编译:
        int storage(String s) {
             return s.length() * 2;
        }
        这个方法告诉我们需要多少字节才能容纳一个特定字串里的信息(字串里的每个字符都是16位,或者说 2 个
        字节、长整数,以便提供对Unicode 字符的支持)。自变量的类型为String,而且叫作 s。一旦将s 传递给
        方法,就可将它当作其他对象一样处理(可向其发送消息)。在这里,我们调用的是 length()方法,它是
        String的方法之一。该方法返回的是一个字串里的字符数。

        通过上面的例子,也可以了解return 关键字的运用。它主要做两件事情:
        首先,它意味着“离开方法,我已完工了”。

        其次,假设方法生成了一个值,则那个值紧接在return 语句的后面。在这种情况下,返回值是通
        过计算表达式“s.length()*2”而产生的。
        可按自己的愿望返回任意类型,但倘若不想返回任何东西,就可指示方法返回void(空)。下面列出一些例
        子。
        boolean flag() { return true; }
        float naturalLogBase() { return 2.718; }
        void nothing() { return; }
        void nothing2() {}
        若返回类型为void,则return 关键字唯一的作用就是退出方法。所以一旦抵达方法末尾,该关键字便不需
        要了。可在任何地方从一个方法返回。但假设已指定了一种非 void 的返回类型,那么无论从何地返回,编译
        器都会确保我们返回的是正确的类型。
        到此为止,大家或许已得到了这样的一个印象:一个程序只是一系列对象的集合,它们的方法将其他对象作
        为自己的自变量使用,而且将消息发给那些对象。这种说法大体正确,但通过以后的学习,大家还会知道如
        何在一个方法里作出决策,做一些更细致的基层工作。



    6.   构建 Java 程序
      •   名字的可见性:做到知名见义、类名字的唯一性(

        Java 的设计者鼓励程序员反转使用自己的Internet 域名,因为它们肯定是独一
        无二的。由于我的域名是BruceEckel.com,所以我的实用工具库就可命名为
        com.bruceeckel.utility.foibles。反转了域名后,可将点号想象成子目录。

      • 使用类库中的类(通过Import导入)

      • static
        Java中static修饰符   java类的成员变量有俩种:
        一种是被static关键字修饰的变量,叫类变量或者静态变量;另一种没有static修饰,为实例变量。    在语法定义上的区别:静态变量前要加static关键字,而实例变量前则不加。  
        在程序运行时的区别:实例变量属于某个对象的属性,必须创建了实例对象,其中的实例变量才会被分配空间,才能使用这个实例变量。静态变量不属于某个实例对象,而是属于类,所以也称为类变量,只要程序加载了类的字节码,不用创建任何实例对象,静态变量就会被分配空间,静态变量就可以被使用了。总之,实例变量必须创建对象后才可以通过这个对象来使用,静态变量则可以直接使用类名来引用。 使用static修饰符来修饰类的成员变量和方法,使他们成为静态成员,也称为类成员。静态成员存储于类的存储区,属于整个类,不属于一个具体的类对象。因此,在不同的类对象中访问静态成员,访问的是同一个。成员声明为static后,无须创建该类中任何对象,直接用类就可以访问。同样,静态方法也可以直接被调用。如果不想再创建对象的时候就需要知道一些相关信息,那么就声明为static类型的,被修饰为static类型的成员变量不属于对象,它是属于类的。  内存,静态变量位于方法区,被类的所有实例共享。静态变量可以直接通过类名进行访问,其生命周期取决于类的生命周期。    而实例变量取决于类的实例。每创建一个实例,java虚拟机就会为实例变量分配一次内存,实例变量位于堆区中,其生命周期取决于实例的生命周期。  
        方法声明为static时,具体有如下限制:
        1 只能调用其他static方法和使用static属性。
        2 不能使用关键字this或super
        3 static代码块将被只执行一次

        下面是thinking in java中的描述也差不多:

        通常,我们创建类时会指出那个类的对象的外观与行为。除非用new 创建那个类的一个对象,否则实际上并
        未得到任何东西。只有执行了new 后,才会正式生成数据存储空间,并可使用相应的方法。
        但在两种特殊的情形下,上述方法并不堪用。一种情形是只想用一个存储区域来保存一个特定的数据——无
        论要创建多少个对象,甚至根本不创建对象。另一种情形是我们需要一个特殊的方法,它没有与这个类的任
        何对象关联。也就是说,即使没有创建对象,也需要一个能调用的方法。为满足这两方面的要求,可使用
        static(静态)关键字。一旦将什么东西设为static,数据或方法就不会同那个类的任何对象实例联系到一
        起。所以尽管从未创建那个类的一个对象,仍能调用一个 static方法,或访问一些 static数据。而在这之
        前,对于非 static数据和方法,我们必须创建一个对象,并用那个对象访问数据或方法。这是由于非
        static数据和方法必须知道它们操作的具体对象。当然,在正式使用前,由于static方法不需要创建任何
        对象,所以它们不可简单地调用其他那些成员,同时不引用一个已命名的对象,从而直接访问非 static成员
        或方法(因为非static成员和方法必须同一个特定的对象关联到一起)。
        有些面向对象的语言使用了“类数据”和“类方法”这两个术语。它们意味着数据和方法只是为作为一个整
        体的类而存在的,并不是为那个类的任何特定对象。有时,您会在其他一些Java 书刊里发现这样的称呼。
        为了将数据成员或方法设为static,只需在定义前置和这个关键字即可。例如,下述代码能生成一个 static
        数据成员,并对其初始化:
        class StaticTest {
        Static int i = 47;
        }
        现在,尽管我们制作了两个StaticTest 对象,但它们仍然只占据StaticTest.i的一个存储空间。这两个对
        象都共享同样的i。请考察下述代码:
        StaticTest st1 = new StaticTest();
        StaticTest st2 = new StaticTest();
        此时,无论 st1.i还是 st2.i都有同样的值 47,因为它们引用的是同样的内存区域。
        有两个办法可引用一个 static变量。正如上面展示的那样,可通过一个对象命名它,如st2.i。亦可直接用
        它的类名引用,而这在非静态成员里是行不通的(最好用这个办法引用static 变量,因为它强调了那个变量
        的“静态”本质)。
        StaticTest.i++;
        其中,++运算符会使变量增值。此时,无论 st1.i 还是st2.i 的值都是48。
        类似的逻辑也适用于静态方法。既可象对其他任何方法那样通过一个对象引用静态方法,亦可用特殊的语法
        格式“类名.方法()”加以引用。静态方法的定义是类似的:
        class StaticFun {
        static void incr() { StaticTest.i++; }
        }
        从中可看出,StaticFun 的方法 incr()使静态数据 i增值。通过对象,可用典型的方法调用incr():
        StaticFun sf = new StaticFun();
        sf.incr();
        或者,由于 incr()是一种静态方法,所以可通过它的类直接调用:
        StaticFun.incr();
        尽管是“静态”的,但只要应用于一个数据成员,就会明确改变数据的创建方式(一个类一个成员,以及每
        个对象一个非静态成员)。若应用于一个方法,就没有那么戏剧化了。对方法来说,static一项重要的用途
        就是帮助我们在不必创建对象的前提下调用那个方法。正如以后会看到的那样,这一点是至关重要的——特
        别是在定义程序运行入口方法main()的时候。
        和其他任何方法一样,static方法也能创建自己类型的命名对象。所以经常把 static方法作为一个“领头
        羊”使用,用它生成一系列自己类型的“实例”。


    7.   注释:java中有三种注释
      1.   //单行注释
      2.   /*这里面放置内容,这是多行注释*/
      3.   /**这里面放内容,这是类注释*/,对于内注释这里要多说几句:

        有三种类型的注释文档,它们对应于位于注释后面的元素:类、变量或者方法。也就是说,一个类注释正好
        位于一个类定义之前;变量注释正好位于变量定义之前;而一个方法定义正好位于一个方法定义的前面。如
        下面这个简单的例子所示:
        /** 一个类注释 */
        public class docTest {
        /** 一个变量注释 */
        public int i;
        /** 一个方法注释 */
        public void f() {}
        }
        注意javadoc只能为 public(公共)和protected(受保护)成员处理注释文档。“private”(私有)和
        “友好”成员的注释会被忽略,我们看不到任何输出(也可以用-private标记包括private 成
        员)。这样做是有道理的,因为只有public 和protected 成员才可在文件之外使用,这是客户程序员的希
        望。然而,所有类注释都会包含到输出结果里。

    8.   编码样式:

      一个非正式的Java 编程标准是大写一个类名的首字母。若类名由几个单词构成,那么把它们紧靠到一起(也
      就是说,不要用下划线来分隔名字)。此外,每个嵌入单词的首字母都采用大写形式。例如:
      class AllTheColorsOfTheRainbow { // ...}
      对于其他几乎所有内容:方法、字段(成员变量)以及对象句柄名称,可接受的样式与类样式差不多,只是
      标识符的第一个字母采用小写。例如:
      class AllTheColorsOfTheRainbow {
         int anIntegerRepresentingColors;
            void changeTheHueOfTheColor(int newHue) {
           // ...
          }
        // ...
      }
      当然,要注意用户也必须键入所有这些长名字,而且不能输错。

      



        

      

      

  • 相关阅读:
    easymock
    MySQL同时执行多条SQL语句解决办法
    MOP
    织梦dedecmsV5.7联动类型无法显示的处理方法
    如何查看sublime安装了哪些插件
    漏洞安全防范
    本地如何使用phpstudy环境搭建多站点
    Sublime 安装、插件CoolFormat
    CSharp设计模式读书笔记(2):工厂方法模式(学习难度:★★☆☆☆,使用频率:★★★★★)
    解决Unable to locate theme engine in module_path: "pixmap"
  • 原文地址:https://www.cnblogs.com/wangyang108/p/5764827.html
Copyright © 2020-2023  润新知