第六章 类与方法进阶

第六章 类与方法进阶

本章继续讨论上一章开始的方法和类。它涵盖了与方法相关的几个主题,包括重载、参数传递和递归。然后,本章回到类的讨论,包括访问控制、关键字static的使用,以及Java中最重要的内置类之一:String。

6.1 方法重载

在Java中,可以在同一个类中定义两个或多个具有相同名称的方法,只要它们的参数声明不同即可,这被称为方法重载。方法重载是Java支持多态性的一种方式。如果你从未使用过允许方法重载的语言,那么这个概念起初可能会显得陌生。方法重载是Java最令人兴奋和有用的特性之一。 当调用一个重载方法时,Java将使用参数的类型和/或数量作为指导,确定实际调用哪个版本的重载方法。因此,重载方法的参数类型和/或数量必须不同。虽然重载方法可以具有不同的返回类型,但仅返回类型是不足以区分两个方法版本的。 当Java遇到对重载方法的调用时,它只会执行参数与调用中使用的参数匹配的方法版本。 下面是一个简单的例子,用来说明方法重载:

package com.mycompany.overload;

//展示方法的重载
class OverloadDemo{
    void test(){
        System.out.println("没有参数");
    }
    
    //重载有一个整形参数的test函数
    void test(int a){
        System.out.println("a:"+a);
    }
    
    //重载有两个参数的函数
    void test(int a,int b){
        System.out.println("a和b:"+a+" "+b+" ");
    }
    
    //重载一个只有一个双精度浮点参数的函数
    double test(double a){
        System.out.println("double a:"+a);
        return a*a;
    }
}


public class Overload {

    public static void main(String[] args) {
        OverloadDemo Od=new OverloadDemo();
        double result;
        
        //调用不同版本的test
        Od.test();
        Od.test(10);
        Od.test(10,20);
        result=Od.test(123.25);
        System.out.println("Result of Od.test(123.25):"+result);
    }
}

它的输出为:

没有参数
a:10
a和b:10 20 
double a:123.25
Result of Od.test(123.25):15190.5625

在上面的代码中,test()被重载了四次。第一个版本的test()不接受任何参数,第二个版本接受一个整数参数,第三个版本接受两个整数参数,第四个版本接受一个双精度参数。第四个版本的test()方法还返回一个值与重载无关,因为返回类型在重载决议中不起作用。 当调用一个重载方法时,Java会查找调用方法时使用的参数与方法的参数之间的匹配。然而,这种匹配不一定总是精确的。在某些情况下,Java的自动类型转换可以在重载决议中发挥作用。例如以下程序:

package com.mycompany.overload;

//展示方法的重载
class OverloadDemo{
    void test(){
        System.out.println("没有参数");
    }
    
    
    //重载有两个参数的函数
    void test(int a,int b){
        System.out.println("a和b:"+a+" "+b+" ");
    }
    
    //重载一个只有一个双精度浮点参数的函数
    double test(double a){
        System.out.println("double a:"+a);
        return a*a;
    }
}


public class Overload {

    public static void main(String[] args) {
        OverloadDemo Od=new OverloadDemo();
        int i=88;
        
        //调用不同版本的test
        Od.test();
        Od.test(10);
        Od.test(10,20);
        Od.test(i);//这将调用double test(double a)
        Od.test(123.2);//这将调用double test(double a)
    }
}

它的输出为:

没有参数
double a:10.0
a和b:10 20 
double a:88.0
double a:123.2

这个版本的OverloadDemo没有定义test(int)方法。因此,在Overload内部调用test()并传入一个整数参数时,找不到匹配的方法。然而,Java可以自动将整数转换为双精度浮点数,并且这个转换可以用于解析调用。因此,在找不到test(int)方法后,Java将整数i提升为双精度浮点数,然后调用test(double)方法。当然,如果定义了test(int)方法,它将不调用test(double)方法。只有在找不到精确匹配的方法时,Java才会使用自动类型转换。

方法的重载实现了Java多态性,因为它是Java实现“一个接口,多个方法”范式的一种方式。在不支持方法重载的语言中,每个方法必须具有唯一的名称。然而,通常情况下,您会希望为不同类型的数据实现基本相同的方法。最典型的例子是绝对值函数。在不支持重载的语言中,通常会有三个或更多版本的绝对值函数,每个版本的名称略有不同。例如,在C语言中,函数abs()返回整数的绝对值,labs()返回长整数的绝对值,fabs()返回浮点数的绝对值。由于C不支持重载,每个函数都有自己的名称,即使这三个函数基本上做的是同样的事情。这样的作法显得非常冗余。虽然每个函数的基本操作是相同的,但调用者仍然需要记住区别三个名称,这非常麻烦。这种情况在Java中不会发生,因为每个绝对值方法都可以使用相同的名称。实际上,Java的标准类库包括一个名为abs()的绝对值方法。Java的Math类通过重载该方法来处理所有数值类型。Java根据参数的类型确定调用哪个版本的abs()方法。

重载的价值在于它允许通过使用一个共同的名称来访问相关的方法。因此,名称abs代表着正在执行的通用操作。选择正确的具体版本取决于编译器在特定情况下的选择。作为程序员,你只需要记住正在执行的通用操作。通过应用多态性,多个类似的操作方法被简化为一个名称。虽然这个例子相当简单,但如果你扩展这个概念,你就能看到重载如何帮助你处理更复杂的情况。 当你重载一个方法时,该方法的每个版本都可以执行任何你想要的操作。Java并没有规定所有重载的方法必须相互关联。然而,从风格的角度来看,方法重载意味着一种关系。因此,虽然你可以使用相同的名称来重载不相关的方法,但你不应该这样做。例如,你可以使用名称sqr来创建返回整数的平方和返回浮点数的平方根的方法。但这两个操作在本质上是不同的。以这种方式应用方法重载会削弱其原始目的。在实践中,你应该只重载密切相关的操作。

6.1.1 构造函数的重载

除了重载普通方法外,你还可以重载构造函数。在实际编程中,重载构造函数是常态而不是特例。为了理解其原因,让我们回顾一下前一章中开发的Box类。下面是之前版本的Box:

class Box{
    double width;
    double height;
    double depth;
    
    //这个是构造函数
    Box(double w,double h,double d){
        width=w;
        height=h;
        depth=d;
    }
    
    //计算体积并返回体积的大小
    double volume(){
        return width*height*depth;
    }
}

上面的代码块中Box()构造函数需要三个参数。这意味着所有的Box对象声明都必须向Box()构造函数传递三个参数。例如,以下语句目前是无效的:

Box ob = new Box();

由于Box()需要三个参数,在没有这些参数的情况下调用它是错误的。这引发了一些重要的问题。如果你只想要一个盒子,而不关心(或者不知道)它的初始尺寸是多少怎么办?或者,如果你想要通过只指定一个值来初始化一个立方体,该值将用于所有三个维度怎么办?当前的Box类写法下,这些选项对你来说是不可用的。

幸运的是,解决这些问题的方法非常简单:只需重载Box构造函数,以处理刚刚描述的情况。下面是一个包含改进版本的Box的程序,它就是这样做的:

package com.mycompany.overloadcons;

class Box{
    double width;
    double height;
    double depth;
    
    //有所有参数的构造函数
    Box(double w,double h, double d){
        width=w;
        height=h;
        depth=d;
    }
    
    //没有参数的构造函数
    Box(){
        width=-1;
        height=-1;
        depth=-1;
    }
    
    //只有一个参数的时候,我们默认创建了立方体
    Box(double len){
        width=height=depth=len;
    }
    
    //计算体积
    double volume(){
        return width*height*depth;
    }
}

public class OverloadCons {

    public static void main(String[] args) {
        //用不同的构造函数构造Box类
        Box mybox1=new Box(10,20,15);
        Box mybox2=new Box();
        Box mybube =new Box(7);
        
        double vol;
        
        //计算第一个盒子的体积
        vol=mybox1.volume();
        System.out.println("第一个盒子的体积为"+vol);
        
        //计算第二个盒子的体积
        vol=mybox2.volume();
        System.out.println("第二个盒子的体积为"+vol);
        
        //计算第三个盒子的体积
        vol=mybube.volume();
        System.out.println("立方体的体积为"+vol);
    }
}

它的输出为:

第一个盒子的体积为3000.0
第二个盒子的体积为-1.0
立方体的体积为343.0

所有的构造函数都是和相应的参数类型,数量相匹配的。

6.2 使用对象作为参数

到目前为止,我们只使用简单类型作为方法的参数。 然而,将对象作为给方法的参数既是合法的,也是常见的做法。例如下面这个简短的程序:

package com.mycompany.passob;

//将对象传递给方法
class Test{
    int a,b;
    
    Test(int i,int j){
        a=i;
        b=j;
    }
    
    //如果o和引用的对象完全相同,就返回布尔值true
    boolean equalTo(Test o){
        if(o.a==a&&o.b==b) return true;
        else return false;
    }
}


public class PassOb {

    public static void main(String[] args) {
        Test ob1=new Test(100,22);
        Test ob2=new Test(100,22);
        Test ob3=new Test(-1,-1);
        
        System.out.println("ob1==ob2 "+ob1.equalTo(ob2));
        System.out.println("ob1==ob3 "+ob1.equalTo(ob3));
    }
}

它的输出为:

ob1==ob2 true
ob1==ob3 false

在上面的代码中,Test内部的equalTo()方法比较两个对象是否相等,并返回结果。也就是说,它将调用对象与方法的参数的对象进行比较。如果它们包含相同的值,方法就返回true,否则返回false。请注意,在equalTo()中参数o指定了它的类型为Test。尽管Test是程序创建的一个类类型,但它的使用方式与Java内置类型完全相同。

在构造函数中,对象也常常作为方法的参数。有时我们希望构造一个新对象,使其最初与某个现有对象相同。为此,我们可以定义一个以其类的对象作为参数的构造函数。例如,下面的Box版本允许一个对象初始化另一个对象:

package com.mycompany.overloadcons2;

class Box{
    double width;
    double height;
    double depth;
    
    //这个构造函数用一个对象作为参数
    Box(Box ob){ 
        width=ob.width;
        height=ob.height;
        depth=ob.depth;
    }
    
    //当所有的尺寸都给出时的构造函数
    Box(double w,double h,double d){
        width=w;
        height=h;
        depth=d;
    }
    
    //没有参数的构造函数
    Box(){
        width=-1;
        height=-1;
        depth=-1;
    }
    
    //只有一个参数的时候,我们默认创建了立方体
    Box(double len){
        width=height=depth=len;
    }
    
    //计算体积
    double volume(){
        return width*height*depth;
    }
}


public class OverloadCons2 {

    public static void main(String[] args) {
        //用不同的构造函数
        Box mybox1=new Box(10,20,15);
        Box mybox2=new Box();
        Box mycube=new Box(7);
        
        Box myclone=new Box(mybox1); //创造mybox1的拷贝
        double vol;
        
        //计算第一个盒子的体积
        vol=mybox1.volume();
        System.out.println("第一个盒子的体积为"+vol);
        
        //计算第二个盒子的体积
        vol=mybox2.volume();
        System.out.println("第二个盒子的体积为"+vol);
        
        //计算立方体的体积
        vol=mycube.volume();
        System.out.println("立方体的体积为"+vol);
        
        //拷贝对象的体积
        vol=myclone.volume();
        System.out.println("拷贝对象的体积为"+vol);
    }
}

当您开始创建自己的类时,您会发现通常需要提供多种形式的构造函数,以便以便捷和高效的方式构建对象。

6.3 参数传递进阶

一般来说,计算机语言传递参数给子程序的方式有两种。第一种方式是按值传递(call-by-value)。这种方式将参数的值复制到子程序的形式参数中。因此,对子程序参数的更改不会影响原始参数。第二种方式是按引用传递(call-by-reference)。在这种方式中,传递给形式参数的是参数的引用(而不是参数的值)。在子程序内部,该引用用于访问调用中指定的实际参数。这意味着对参数的更改将影响调用子程序时使用的参数。虽然Java使用按值传递来传递所有参数,但是在传递基本类型还是引用类型时,其效果有所不同。 当您将基本类型传递给方法时,它是按值传递的。因此,参数的副本被创建,并且在方法内部对接收该参数的形式参数所做的更改不会影响方法之外的内容。例如以下程序:

package com.mycompany.callbyvalue;

class Test{
    void meth(int i,int j){
        i*=2;
        j/=2;
    }
}


public class CallByValue {

    public static void main(String[] args) {
        Test ob=new Test();
        
        int a=15,b=20;
        
        System.out.println("a和b在调用之前的值:"+a+" "+b);
        
        ob.meth(a,b);
        
        System.out.println("a和b在调用之后的值:"+a+" "+b);
    }
}

它的输出为:

a和b在调用之前的值:15 20
a和b在调用之后的值:15 20

meth()方法内部执行的操作对调用中使用的a和b的值没有影响;它们的值没有改变为30和10。当您将对象传递给方法时,情况发生了显著变化,因为对象实际上是通过引用传递的。请记住,当您创建一个类类型的变量时,实际上只是创建了对对象的引用。因此,当您将该引用传递给方法时,接收它的参数将引用与参数引用的相同的对象。这意味着方法内部对对象的更改会影响作为参数使用的对象。例如以下程序:

package com.mycompany.passobjref;

class Test{
    int a,b;
    
    Test(int i,int j){
        a=i;
        b=j;
        
    }
    
    //传递一个对象
    void meth(Test o){
        o.a*=2;
        o.b/=2;
    }
}


public class PassObjRef {

    public static void main(String[] args) {
        Test ob=new Test(15,20);
        System.out.println("ob.a和ob.b在调用之前的值分别为:"+ob.a+" "+ob.b);
        
        ob.meth(ob);
        
        System.out.println("ob.a和ob.b在调用之后分别为:"+ob.a+" "+ob.b);
    }
}

它的输出为:

ob.a和ob.b在调用之前的值分别为:15 20
ob.a和ob.b在调用之后分别为:30 10

可见当对象作为方法的参数的时候,在方法内部对对象的操作是会影响到对象本身的

6.4 返回对象

一个方法可以返回任何类型的数据,包括您创建的类类型。例如,在下面的程序中,incrByTen()方法返回一个对象,其中a的值比调用对象中的值大十。

package com.mycompany.retob;

class Test{
    int a;
    
    Test(int i){
        a=i;
    }
    
    Test incrByTen(){
        Test temp=new Test(a+10);
        return temp;
    }
}

public class RetOb {

    public static void main(String[] args) {
        Test ob1=new Test(3);
        Test ob2;
        
        ob2=ob1.incrByTen();
        System.out.println("ob1.a:"+ob1.a);
        System.out.println("ob2.a:"+ob2.a);
        
        ob2=ob2.incrByTen();
        System.out.println("现在的ob2.a:"+ob2.a);
    }
}

每次调用incrByTen()时都会创建一个新的对象,并将对它的引用返回给调用程序。由于所有对象都是使用new进行动态分配的,您不需要担心对象超出作用域而消失。只要程序中的某个地方存在对它的引用,该对象就会继续存在。当没有引用指向它时,对象将在下一次进行垃圾回收时被回收。

6.5 递归

递归是Java中的一个超酷特性!它是一种自我调用的方式,就像你在短信中无限循环地给自己发消息一样。阶乘是递归的绝佳应用,你只需调用自己,不断地相乘,就像一只疯狂的兔子繁殖后代。这种奇妙的自我迭代方式在Java世界中无处不在,让人兴奋不已!所以,准备好迎接递归的魔力,你会发现它的神奇之处!一个数N的阶乘是从1到N之间所有整数的乘积。例如,3的阶乘是1 × 2 × 3,即6。下面是使用递归方法计算阶乘的示例:

package com.mycompany.recursion;

//一个简单的递归示例
class Factorial{
    //这是一个递归的方法
    int fact(int n){
        int result;
        
        if(n==1) return 1;
        result=fact(n-1)*n;
        return result;
    }
}

public class Recursion {

    public static void main(String[] args) {
        Factorial f=new Factorial();
        
        System.out.println("3的阶乘为"+f.fact(3));
        System.out.println("4的阶乘为"+f.fact(4));
        System.out.println("5的阶乘为"+f.fact(5));
    }
}

它的输出为:

3的阶乘为6
4的阶乘为24
5的阶乘为120

如果你不熟悉递归方法,那么fact()的运作可能会让你有点困惑。当你调用fact()并传入参数1时,它会返回1;否则,它会返回fact(n–1)*n的结果。这个表达式中,fact()会以n–1为参数再次调用自身。这个过程会不断重复,直到n等于1,然后方法开始返回结果。当你计算3的阶乘时,第一次调用fact()会触发第二次调用,参数为2。第二次调用会引发第三次调用,而第三次的调用参数为1,它不会触发更多的调用。第三次调用会返回1,然后与第二次调用中的n(值为2)相乘。这个结果(即2)然后返回给最初的fact()调用,并与n(原始值为3)相乘。这就得到了答案6。如果你在fact()中插入println()语句,你会看到每个调用的级别以及中间结果。当一个方法调用自身时,新的局部变量和参数会在系统栈上分配存储空间,并且使用这些新变量从头开始执行方法代码。每次递归调用返回时,旧的局部变量和参数会从栈中移除,执行会回到方法内调用点的位置。可以说,递归方法就像是“望远镜”一样,不断地展开和收缩。

由于额外的方法调用所带来的开销,递归版本的许多常规算法可能执行速度稍慢于迭代版本。对方法进行大量递归调用可能会导致栈溢出。因为参数和局部变量的存储空间位于栈上,每次新的调用都会创建这些变量的新副本,所以有可能耗尽栈空间。如果发生这种情况,Java运行时系统会引发异常。不过,通常情况下,除非递归程序失控,否则这不会成为一个问题。

递归方法的主要优势在于它们可以用于创建比迭代版本更清晰简单的多个算法。例如,快速排序算法在迭代方式下实现相当困难。此外,某些类型的与人工智能相关的算法最容易通过递归解决方案来实现。

写递归方法时,你必须在某处使用一个条件语句,强制方法在特定的条件被满足时,不再执行递归调用并返回。如果你不这样做,一旦调用该方法,它将永远不会返回。这是在处理递归时非常常见的错误。在开发过程中我们可以大量使用println()语句,这样你就可以观察程序的执行情况,并在发现错误时终止执行。

下面再给你一个递归的例子。递归方法printArray()用于打印数组values中的前i个元素。

//另一个递归的例子
package com.mycompany.recursion2;

class RecTest{
    int values[];
    
    RecTest(int i){
        values=new int[i];
    }
    
    //展示数组
    void printArray(int i){
        if(i==0) return;
        else printArray(i-1);
        System.out.println("["+(i-1)+"]"+values[i-1]);
    }
}


public class Recursion2 {

    public static void main(String[] args) {
        RecTest ob=new RecTest(10);
        int i;
        
        for(i=0;i<10;i++) ob.values[i]=i;
        
        ob.printArray(10);
    }
}

它的输出为:

[0]0
[1]1
[2]2
[3]3
[4]4
[5]5
[6]6
[7]7
[8]8
[9]9

6.6 访问控制

封装将数据与操作数据的代码关联起来。 然而,封装还提供了另一个重要的特性:访问控制。 通过封装,你可以控制程序的哪些部分可以访问类的成员。通过只允许一组明确定义的方法访问数据,你可以防止对数据的滥用。因此,当正确实现类时,一个类相当于创建了一个"黑盒",它可以被使用,但其中的内部工作不被篡改。然而,在前面介绍的类中,并没有完全实现这个目标。例如,第5章末尾展示的Stack类。虽然push()pop()方法确实为栈提供了受控的接口,但这个接口并没有被强制执行。也就是说,程序的其他部分可以绕过这些方法直接访问栈。当然,在错误的使用情况下,这可能会带来麻烦。在本节中,你将了解到如何精确控制类的各个成员的访问机制。

成员如何被访问取决于附加到其声明上的访问修饰符。Java提供了一套丰富的访问修饰符。访问控制的某些方面与继承或包相关。(包实质上是类的分组。)Java的访问控制机制的这些部分将在后续章节中讨论。在这里,让我们从研究适用于单个类的访问控制开始。一旦你理解了访问控制的基本原理,剩下的将会很容易。

Java的访问修饰符有publicprivateprotected。Java还定义了默认的访问级别。protected仅在涉及继承时适用。

我们先来介绍publicprivate。当一个类的成员被public修饰时,该成员可以被任何其他代码访问。当一个类的成员被指定为private时,该成员只能被其类的其他成员访问。现在你可以理解为什么main()方法一直以来都被public修饰。它被程序外部的代码调用,也就是被Java运行时系统调用。当不使用访问修饰符时,默认情况下,类的成员在其所在的包内是public的,但无法在包(package)外部访问。(包在第8章中讨论。)

到目前为止,我们所写的类中所有成员都使用了默认的访问模式。然而通常,我们可能希望限制对类的数据成员的访问,只允许通过方法访问。此外,我们有时还希望定义对类私有的方法。

下面是使用不同访问修饰符的方法:

  1. public修饰符:将成员声明为public,可以被任何地方的代码访问。例如:

    public class MyClass {
        public int myPublicVariable;
        public void myPublicMethod() {
            // 公共方法的实现
        }
    }
    
  2. private修饰符:将成员声明为private,只能在所属的类内部访问。其他类无法直接访问private成员。例如:

    public class MyClass {
        private int myPrivateVariable;
        private void myPrivateMethod() {
            // 私有方法的实现
        }
    }
    
  3. protected修饰符:将成员声明为protected,可以在同一包内以及子类中访问。其他包中的类无法访问protected成员。例如:

    public class MyClass {
        protected int myProtectedVariable;
        protected void myProtectedMethod() {
            // 受保护方法的实现
        }
    }
    
  4. 默认访问修饰符:如果成员没有使用public、private或protected进行修饰,那么它具有默认的访问级别。默认访问级别下,成员对于同一包内的类是可见的,但对于不在同一包内的类是不可见的。

通过选择适当的访问修饰符,我们可以控制类成员的可见性,实现封装和保护数据的安全性。下面我们用一段程序来阐明访问控制的效果:

package com.mycompany.accesstest;

class Test{
    int a;//默认访问级别
    public int b;//公共访问
    private int c;//私有访问
    
    //通过方法访问c
    void setc(int i){
        //设置c的值
        c=i;
    }
    int getc(){//获得c的值
        return c;
    }
}
public class AccessTest {

    public static void main(String[] args) {
        Test ob=new Test();
        
        //a和b可以直接赋值
        ob.a=10;
        ob.b=20;
        
        //以下的代码是错误的!
        //ob.c=100;//这是错误的,因为变量c是私有成员
        
        //我们必须通过方法访问c
        ob.setc(100);
        System.out.println("a,b,c的值分别为"+ob.a+" "+ob.b+" "+ob.getc());
    }
}

在Test类内部,a使用默认访问修饰符,对于此示例而言,与指定为public相同。b被指定为public。成员c被指定为私有访问。这意味着它无法被类外的代码访问。因此,在AccessTest类内部,无法直接使用c。必须通过其公共方法setc()和getc()来访问它。如果您将以下行的注释符号从开头移除:

// ob.c = 100; // Error!

那么由于访问权限冲突,您将无法编译此程序。 为了看到如何将访问控制应用于更实际的示例,我们将改进第五章末尾的Stack类。

//这个类定义了一个可以容纳10个整形的栈
class Stack{
    //现在我们将修改这个栈的访问控制权限,以防恶意修改栈中的内容
    
    private int stck[]=new int[10];
    private int tos;
    
    //初始化栈顶
    Stack(){
        tos=-1;
    }
    
    //将一个数据压入栈中
    void push(int item){
        if(tos==9)
            System.out.println("栈满了");
        else
            stck[++tos]=item;
    }
    
    //将一个数据从栈中弹出
    int pop(){
        if(tos<0){
            System.out.print("栈下溢");
             return 0;
        }
        else
        {
            return stck[tos--];
        }
    }
}

现在stck和tos都被指定为私有(private)。这意味着除了通过push()pop()方法之外,不能直接访问或修改它们。将tos设置为私有可以防止程序中其他部分无意中将其设置为超出stck数组末尾的值。 以下程序演示了改进后的Stack类。我们可以尝试移除被注释掉的行,以证明stck和tos成员确实是无法访问的。

public class TestStack {

    public static void main(String[] args) {
        Stack mystack1=new Stack();
        Stack mystack2=new Stack();
        
        //将一些数值压入栈中
        for(int i=0;i<10;i++) mystack1.push(i);
        for(int i=10;i<20;i++)mystack2.push(i);
        
        //将这些数值从栈中弹出
        System.out.println("Stack in mystack1");
        for(int i=0;i<10;i++)
            System.out.println(mystack1.pop());
        
        System.out.println("Stack in mystack2:");
        for(int i=0;i<10;i++)
            System.out.println(mystack2.pop());
        
        //以下的语句都是非法的
        //mystack1.tos=-2;
        //mystack2.stck[3]=100;
    }
}

在下一章我们介绍到类的继承的时候,访问控制就是十分重要的。

6.7 static关键字

static是一个非常有趣的关键字。它使得成员不再与对象绑定,这意味着您可以在没有任何对象实例的情况下访问它们。您可以在类级别上使用它们,而无需创建类的实例。这对于共享数据或者定义通用的实用方法非常有用。

在Java中,我们可以将变量和方法声明为static。静态变量在所有实例之间共享相同的值,而静态方法可以直接调用,而不需要先创建对象。静态变量在内存中只有一个副本,并且在类加载时进行初始化。它们可以通过类名直接访问,而不需要实例化类。例如,可以使用"ClassName.variableName"来访问静态变量。

声明为静态的方法有几个限制:

• 它们只能直接调用其所属类的其他静态方法

• 它们只能直接访问其所属类的静态变量

• 它们不能以任何方式引用this或super。(关键字super与继承有关,将在下一章中进行描述。)

对于静态变量在内存中只有一个拷贝(节省内存),JVM只为静态变量分配一次内存,在加载类的过程中完成静态变量的内存分配,可用类名直接访问(方便),当然也可以通过对象来访问(但是这是不推荐的)。

对于实例变量,每创建一个实例,就会为实例变量分配一次内存,实例变量可以在内存中有多个拷贝,互不影响(灵活)。

static代码块也叫静态代码块,是在类中独立于类成员的static语句块,可以有多个,位置可以随便放,它不在任何的方法体内,JVM加载类时会执行这些静态的代码块,如果static代码块有多个,JVM将按照它们在类中出现的先后顺序依次执行它们,每个代码块只会被执行一次。

一个常见的例子是在Java应用程序的入口点中,main()方法被声明为静态方法。它是程序的起始点,并且在没有任何对象之前被调用。最后,静态成员不能访问非静态成员,因为非静态成员需要通过对象引用才能访问。下面的程序展示了static的用法:

package com.mycompany.usestatic;


public class UseStatic {
    static int a=3;
    static int b;
    
    static void meth(int x){
        System.out.println("x="+x);
        System.out.println("a="+a);
        System.out.println("b="+b);
    }
    
    static {
        System.out.println("Static block initialized");
        b=a*4;
    }

    public static void main(String[] args) {
        meth(42);
    }
}

UseStatic类被加载后,所有的static语句会立即执行。首先,变量a被设置为3,然后执行静态代码块,打印一条消息,并将变量b初始化为a*4。接下来调用main()方法,它调用meth()方法,并将42传递给参数x。三个println()语句分别引用了两个静态变量a和b,以及参数x。

以下是程序的输出结果:

Static block initialized
x=42
a=3
b=12

在定义它们的类之外,静态方法和变量可以独立于任何对象使用。要这样做,您只需要指定它们的类名,后面跟着点运算符。例如,如果您希望从其类外部调用静态方法,可以使用以下一般形式:

类名.方法名()

下面是一个例子。在main()方法内部,通过它们的类名StaticDemo访问了静态方法callme()和静态变量b。

package com.mycompany.staticbyname;

class StaticDemo{
    static int a=42;
    static int b=99;
    
    static void callme(){
        System.out.println("a="+a);
    }
}
public class StaticByName {

    public static void main(String[] args) {
        StaticDemo.callme();
        System.out.println("b="+StaticDemo.b);
    }
}

它的输出为:

a = 42
b = 99

6.8 final关键字

final关键字在Java中用于表示最终的实体,它可以应用于变量、方法和类。当应用于变量时,表示该变量的值是不可更改的,即它是一个常量。当应用于方法时,表示该方法不能被重写。当应用于类时,表示该类不能被继承。这些是final关键字的主要用法和作用。而关于继承和重写将在后续的章节继续介绍。

final应用于变量时,表示该变量的值是不可变的,即它是一个常量。一旦给变量赋予初始值,就无法再修改它。常量的命名通常使用全大写字母,并用下划线分隔单词。

final int MAX_VALUE = 10;//在后面的程序中,它的值不能改变

final应用于方法时,表示该方法不能被子类重写。这在继承关系中可以用于确保方法的行为不会被修改。我们将在下一章详细诉说,这里先开个头。

class Parent {
    final void printMessage() {
        System.out.println("This is a final method.");
    }
}

class Child extends Parent {
    // 编译错误,无法重写父类的final方法
    void printMessage() {
        System.out.println("Cannot override final method.");
    }
}

final应用于类时,表示该类不能被继承,即它是最终类,不能有子类。

final class FinalClass {
    // 类的内容
}

// 编译错误,无法继承最终类
class SubClass extends FinalClass {
    // 类的内容
}

使用final关键字可以提供更好的性能、安全性和设计约束,同时也允许编译器进行一些优化。需要注意的是,对于基本数据类型的变量,final表示其值不可变,而对于引用类型的变量,final表示其引用不可变,但对象的状态可以改变。

当使用final关键字修饰引用类型的变量时,意味着一旦该变量被赋予一个对象的引用,就不能再指向其他对象。这是因为final修饰的变量只能被赋值一次。

然而,虽然引用不可变,所引用的对象本身的状态仍然可以被修改。也就是说,可以通过final修饰的引用变量来调用对象的方法或修改对象的可变属性。在下一章我们将会更加详细讲解final的用法。

6.9 重温Array

在前面的章节,我们介绍了数组的概念,但那时还没有讨论类。现在你了解了类的知识,可以对数组进行进一步的介绍,数组被实现为Array对象。Array类中有一个记录了数组长度的成员变量,我们可以在Array类的成员变量length中找到数组的长度。所有Array类都具有这个变量,并且它始终保存着数组的大小。以下是演示此属性的程序示例:

package com.mycompany.arraylength;


public class ArrayLength {

    public static void main(String[] args) {
        int a1[]=new int [10];
        int a2[]={6,78,5,69,10,25,64,23};
        int a3[]={1,2,3,4,5};
        
        System.out.println("数组a1的长度为"+a1.length);
        System.out.println("数组a2的长度为"+a2.length);
        System.out.println("数组a3的长度为"+a3.length);
    }
}

它的输出为:

数组a1的长度为10
数组a2的长度为8
数组a3的长度为5

在上面的程序中,length反映了每个数组的长度,在许多情况下,你可以充分利用length成员变量。例如,下面是Stack类的改进版本。你可能还记得,早期版本的这个类总是创建一个大小为十的堆栈。下面的版本允许你创建任意大小的堆栈。stck.length的值被用来防止堆栈溢出。

package com.mycompany.teststack2;

//改进的stack类
class Stack{
    private int stck[];
    private int tos;
    
    //初始化栈顶
    Stack(int size){
        stck=new int[size];
        tos=-1;
    }
    
    //将一个数据压入栈中
    void push(int item){
        if(tos==stck.length-1)
            System.out.println("栈满了");
        else
            stck[++tos]=item;
    }
    
    //将一个数据从栈中弹出
    int pop(){
        if(tos<0){
            System.out.print("栈下溢");
             return 0;
        }
        else
        {
            return stck[tos--];
        }
    }
}
public class TestStack2 {

    public static void main(String[] args) {
        Stack mystack1=new Stack(5);
        Stack mystack2=new Stack(8);
        
        //将一些数字压入栈
        for(int i=0;i<5;i++)mystack1.push(i);
        for(int i=0;i<8;i++)mystack2.push(i);
        
        //将元素弹出
        System.out.println("mystack1中的元素:");
        for(int i=0;i<5;i++){
            System.out.println(mystack1.pop());
        }
        
        System.out.println("mystack2中的元素:");
        for(int i=0;i<8;i++){
            System.out.println(mystack2.pop());
        }
    }
}

6.10 嵌套类和内部类

在一个类的内部定义另一个类,这种类称为嵌套类(nested classes)。在介绍嵌套类之前,我们再引入一个概念,封闭类。封闭类是指包含其他类或接口的类。也就是说,封闭类是充当其他类或接口的容器或外部包装器。简而言之,嵌套类是封闭类的子集。封闭类可以包含其他类(嵌套类)作为其成员。

嵌套类的作用域受限于其封闭类的作用域。因此,如果类B在类A内部定义,则B不能独立于A存在。嵌套类可以访问其嵌套所在类的成员,包括私有成员。然而,封闭类不能访问嵌套类的成员。这是出于对封闭类的封装的考虑。

有两种类型的嵌套类:静态嵌套类和非静态嵌套类。静态嵌套类具有静态修饰符static,而非静态嵌套类没有静态修饰符。因为它是静态的,所以必须通过对象访问其封闭类的非静态成员。也就是说,它不能直接引用其封闭类的非静态成员。由于这种限制,我们很少使用静态嵌套类。

内部类是一种非静态的嵌套类。它可以访问其外部类的所有变量和方法,并且可以直接引用它们,就像外部类的其他非静态成员一样。

下面的程序示例演示了如何定义和使用内部类。名为Outer的类有一个实例变量outer_x,一个实例方法test(),并定义了一个称为Inner的内部类。

package com.mycompany.innerclassdemo;
//展示内部类
class Outer{
    int outer_x=100;
    
    class Inner{
        void display(){
            System.out.println("展示outer类中的变量outer_x:"+outer_x);
        }
    }
    
    void test(){
        Inner inner=new Inner();
        inner.display();
        
    }       
}

public class InnerClassDemo {

    public static void main(String[] args) {
        Outer outer=new Outer();
        outer.test();
    }
}

在该程序中,在Outer类的作用域内定义了一个名为Inner的内部类。因此,Inner类中的任何代码都可以直接访问变量outer_x。在Inner类内部又定义了一个名为display()的实例方法。该方法在标准输出流上显示outer_x的值。InnerClassDemo的main()方法创建了一个Outer类的实例,并调用了其test()方法。该方法创建了一个Inner类的实例,并调用display()方法。

注意,只能在Outer类的作用域中创建Inner类的实例。否则,Java编译器会生成错误消息。通常情况下,内部类实例是由其封闭作用域内的代码创建的,就像示例中所示。

再次提醒,内部类可以访问其封闭类的所有成员,但反过来不成立。内部类的成员只在内部类的作用域内可见,外部类不能使用内部类的成员。

我们也可以在任何块作用域内定义内部类。例如,您可以在方法定义的块中或甚至在for循环的主体中定义一个嵌套类,如下面的示例程序所示:

package com.mycompany.innerclassdemo2;

class Outer{
    int outer_x=100;
    
    void test(){
        for(int i=0;i<10;i++){
            class Inner{
                void display(){
                    System.out.println("display:outer_X="+outer_x);
                }
            }
            Inner inner=new Inner();
            inner.display();
         
        }
    }
}
public class InnerClassDemo2 {

    public static void main(String[] args) {
        Outer outer=new Outer();
        outer.test();
    }
}

它的输出为:

display:outer_X=100
display:outer_X=100
display:outer_X=100
display:outer_X=100
display:outer_X=100
display:outer_X=100
display:outer_X=100
display:outer_X=100
display:outer_X=100
display:outer_X=100

到后面的章节,我们会学到这种嵌套的类特别适合处理事件,然而大部分情况下,嵌套的类会增加代码的复杂度,使可读性降低。

6.11 再探String类

对String的深入讨论将放在本笔记的后面,但是现在很有必要重新温习一下String。String是Java类库中最常用的类之一,在第二章我们了解到,它的最大作用是创建字符串。您创建的每个字符串实际上都是String类型的对象。即使是字符串常量也实际上是String对象。例如,在以下语句中:

System.out.println("This is a String, too"); 

字符串"This is a String, too"是一个String对象。

此外,String类型的对象是不可变的;一旦创建了一个String对象,其内容就无法更改。这样可以帮助保护数据的完整性,因为一旦字符串创建后,就无法被篡改。这对于安全性非常重要。此外,不可变字符串还能够提高性能。因为它们是不可变的,可以在需要时重复使用相同的字符串对象,而不必每次都创建新的对象。这样可以节省内存,并且在比较字符串时可以使用一些巧妙的优化技巧。

当然,您可以拥有字符串数组,就像您可以拥有任何其他类型对象的数组一样。例如:

package com.mycompany.stringdemo2;

//展示String数组
public class StringDemo2 {

    public static void main(String[] args) {
        String str[]={"one","two","three"};
        
        for(int i=0;i<str.length;i++){
            System.out.println("str["+i+"]:"+str[i]);
    }
}
}

它的输出为:

str[0]:one
str[1]:two
str[2]:three

6.12 使用命令行参数

有时候,我们希望在运行程序时将信息传递给它。这可以通过将命令行参数传递给main()函数来实现。当程序被执行时,命令行参数是紧跟程序名称的信息。在Java程序内部访问命令行参数非常容易,它们以字符串的形式存储在传递给main()函数的args参数的字符串数组中。第一个命令行参数存储在args[0]中,第二个参数存储在args[1]中,依此类推。例如,下面的程序会显示调用它时的所有命令行参数:

package com.mycompany.commandline;

//展示所有的命令行参数
public class CommandLine {

    public static void main(String[] args) {
        for(int i=0;i<args.length;i++){
            System.out.println("args["+i+"]:"+args[i]);
        }
    }
}

假设我们传入命令行的参数如下:

this is a test 100 -1

那么这个程序的输出为:

args[0]: this
args[1]: is
args[2]: a
args[3]: test
args[4]: 100
args[5]: -1

6.13 Varargs:可变长度参数

从JDK 5开始,Java引入了可变长度参数。它允许我们定义一个方法,在调用该方法时可以传递不定数量的参数。这种灵活性使得方法能够处理不同数量的参数,而无需在代码中显式地指定参数的个数。

向方法传递可变数量参数的情况并不罕见。例如,一个打开网络连接的方法可能需要用户名、密码、文件名、协议等信息,但如果其中一些信息未提供,就提供默认值。在这种情况下,传递可变长度的参数将会非常实用。另一个例子是Java的I/O库中的printf()方法。这将在后面的章节详细阐述。

可变长度参数的优点是它提供了更大的灵活性和方便性,尤其在需要处理不确定数量参数的情况下。我们不再需要手动创建多个重载方法或者将参数放入数组中进行传递,而是直接使用可变长度参数来处理不同数量的参数。

需要注意的是,可变长度参数必须是方法参数列表中的最后一个参数,因为它接受的是多个参数的数组。这样可以避免在调用方法时出现参数歧义。

第一种传递可变长度参数的方法,是传递可变长度的数组,以下是一个示例程序:

//我们用第一种变长数组的方式去传递参数
package com.mycompany.passarray;


public class PassArray {
    static void vaTest(int v[]){
        System.out.print("参数的数量:"+v.length+" 数组的内容:");
        for(int x:v){
            System.out.print(x+" ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        //创建不同的长度的数组
        int n1[]={10};
        int n2[]={1,2,3};
        int n3[]={};
        
        vaTest(n1);//一个参数
        vaTest(n2);//三个参数
        vaTest(n3);
    }
}

它的输出为:

参数的数量:1 数组的内容:10 
参数的数量:3 数组的内容:1 2 3 
参数的数量:0 数组的内容:

在该程序中,方法vaTest()通过数组v传递其参数。这种可变参数处理方式确实可以使vaTest()接受任意数量的参数。然而,它要求在调用vaTest()之前手动将这些参数打包到一个数组中。不仅每次调用vaTest()都需要构建一个数组很繁琐,而且可能容易出错。下面我们将介绍另一种可变参数的传递方法,它提供了一个更简单、更好的选择。

这种可变长度参数的方法由三个连续的句点(…)指定。例如,以下是使用可变参数的方式编写的vaTest()方法:

static void vaTest(int ... v) {

这种语法告诉编译器vaTest()可以接受零个或多个参数。因此,v被隐式声明为int[]类型的数组。在vaTest()内部,可以使用普通的数组语法访问v。下面我们使用可变参数重写的前面的程序:

package com.mycompany.varargs;


public class VarArgs {
    
    //展示变长的参数用法
    static void vaTest(int ... v){
        System.out.print("参数的数量:"+v.length+" 参数的内容:");
        
        for(int x:v){
            System.out.print(x+" ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        //vaTest将会被不同的参数长度调用
        vaTest(10);
        vaTest(1,2,3);
        vaTest();
    }
}

他的输出为:

参数的数量:1 参数的内容:10 
参数的数量:3 参数的内容:1 2 3 
参数的数量:0 参数的内容:

这个程序的输出与原始版本相同。

vaTest()函数内部,v被作为一个数组进行操作。"…"告诉编译器将传递进来的是可变数量的参数,并将这些参数存储在由v引用的数组中。在main()函数中,我们使用不同数量的参数调用vaTest(),包括没有参数的情况。参数会自动放入一个数组中,并传递给v。在没有参数的情况下,数组的长度为零。

再次强调,可变长度参数必须是方法声明的最后一个参数。例如,这个方法声明是完全合法的:

int doIt(int a, int b, double c, int ... vals)

调用doIt()时传递的前三个参数将与前三个参数匹配。然后,任何剩余的参数都被假定属于vals。然而以下声明是不正确的:

int doIt(int a, int b, double c, int ... vals, boolean stopFlag)
{ // Error!
  }

同时,只能有一个可变长度参数。例如,以下声明也是无效的:

int doIt(int a, int b, double c, int ... vals, double ...morevals) { 
    // Error!
}

在使用可变长度的参数的时候,务必要注意避免歧义,例如用以下的方式重载两个可变长度参数的函数:

static void vaTest(int ... v) { // ...
static void vaTest(int n, int ... v) { // ...

尽管两者参数不同,但是编译器对以下的语句无法解析:

vaTest(1)

因为编译器无法判断到底应该使用那个版本的vaTest函数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值