Java编程笔记3:访问权限控制

java权限模块搭建 java权限怎么做_开发语言

图源:Java Switch语句(用法详解)-java教程-PHP中文网

包:库单元

在编写代码的时候,通常会将当前已经命名的变量集合称作“命名空间”,如果仅涉及自己编写的代码,一般来说命名空间中的名称不会出现冲突,但如果引入标准库或者第三方库的代码,就容易出现命名冲突的问题。

显然一个个修改变量名是不可取的,对此,大多数编程语言都会采用包的方式组织和管理代码,以解决此类问题。

代码组织

在Java编程笔记0:Hello World - 魔芋红茶’s blog (icexmoon.xyz)中我介绍过如何使用VSC构建Java项目,并方便地创建包,实际上包就是一系列定义好结构的目录层级。

假设你打算用net.my_project作为你的项目的包,就可以创建一个$CLASSPASS/net/my_project这样的目录。

其中$CLASSPASS代表一个已经添加到CLASSPASS环境变量的有效路径。当然这并非必须,如果你是通过IDE而非命令行来运行程序,就考虑设置相关环境变量。

然后在代码中可以用package来指定代码当前所处的包,比如对于$CLASSPASS/net/my_project/Main.java,就可以:

package net.my_project;
public class Main{
    ...
}

如果在根目录中创建子目录,则包名也同样添加子包。比如创建一个$CLASSPASS/net/my_project/util/Tools.java:

package net.my_project.util;
public class Tools{
    ...
}

需要注意的是,包名必须全部小写。

事实上Windows平台的文件系统是不区分大小写路径的,所以构建工程目录时最好采用全部小写的方式。

要使用标准库或第三方库的代码,需要使用相应的包名,比如:

package ch3.pack;

public class Main {
    public static void main(String[] args) {
        java.lang.System.out.println("111");
    }
}

但显然平时输出信息到控制台时并不会这么写,会省略java.lang.,这是因为java.lang包是标准库中最基础的包,很多常用工具都包含在这个包中,所以编译器会自动会为所有源码添加对这个包的引用,故此不需要明确导入。

如果是其它包,就需要明确包名:

package ch3.pack1;

public class Main {
    public static void main(String[] args) {
        int[] arr = { 1, 2, 3, 4, 5 };
        System.out.println(java.util.Arrays.toString(arr));
        // [1, 2, 3, 4, 5]
    }
}

如果代码中仅使用一次Arrays类,这样做并没有什么问题,但如果有多个地方需要使用,这样写无疑是件很麻烦的事情,所以通常会使用import语句直接将Arrays类导入,这样就可以在当前源码中的任意地方使用:

package ch3.pack2;

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        int[] arr = { 1, 2, 3, 4, 5 };
        System.out.println(Arrays.toString(arr));
        // [1, 2, 3, 4, 5]
    }
}

实际上这种导入工作IDE都可以自动帮你完成,相当方便。如果是已经包含没有导入的类的历史代码,可以使用.+Ctrl的快捷键快速导入。

除了这种方式以外,还可以在导入包时使用通配符*:

package ch3.pack3;

import java.util.*;

public class Main {
    public static void main(String[] args) {
        int[] arr = { 1, 2, 3, 4, 5 };
        System.out.println(Arrays.toString(arr));
        // [1, 2, 3, 4, 5]
    }
}

这意味着所有java.util包下的类都被导入,可以在当前代码中直接使用。

这种方式实际上是为早期缺乏自动导入工具的开发者提供方便,所以在当前看来,这种方式是没有必要的,会导入不需要的类,污染命名空间。

Java并不像Python那样可以随意导入变量、方法、函数等任何命名的东西。但是,Java可以导入静态方法:

package ch3.pack4;

import java.util.Arrays;
import static util.Printer.println;

public class Main {
    public static void main(String[] args) {
        int[] arr = { 1, 2, 3, 4, 5 };
        println(Arrays.toString(arr));
        // [1, 2, 3, 4, 5]
    }
}

这里的util.Printer类是一个为了方便打印的工具类:

package util;

public class Printer {
    public static void println(Object obj) {
        System.out.println(obj);
    }

    public static void println() {
        System.out.println();
    }

    public static void print(Object obj) {
        System.out.print(obj);
    }
}

import static可以称作“静态导入”语句,利用它可以导入静态方法,然后就可以像“本地方法”那样直接进行方法调用。

当然也可以使用*导入某个类的全部的静态方法:

package ch3.pack5;

import static util.Printer.*;

public class Main {
    public static void main(String[] args) {
        print("Hello ");
        println("World!");
        // Hello World!
    }
}

独一无二的包名

有了包以后,我们就不用担心命名空间命名冲突的问题,但是如果包名冲突了咋办?

可能这对个人开发者和某些小型项目来说是“杞人忧天”,但对一些引用广泛的大型项目或者开源项目来说就是切切实实的问题,所以我们需要一些“独一无二的名称”。

事实上这样的名称在互联网高度发达的今天来说司空见惯——URL,所以推荐的包名是利用你拥有的域名来创建。

比如说我的域名是icexmoon.xyz,所以我创建的项目使用的包名应当以xyz.icexmoon.my_project这样来命名。这个命名思路是顶级域名.二级域名.具体项目名称。

如果你没有独立的二级域名可用,也不想麻烦地去购买一个。有个更便捷的方式,即找个代码托管网站,比如Github或者Gitee,注册完帐号后自然就有了一个独一无二的URL,比如我的Github主页是github.com/icexmoon,那么我的包名就可以用com.github.icexmoon.my_project来命名,这同样是独一无二的。

定制工具库

利用包,我们可以给项目添加一些自定义的工具库,让代码开发变得更方便。

其实前边已经展示过如何通过在util包下边添加一个Printer类,提供一些便捷的打印工具函数。如果你熟悉Go语言,可能更习惯的方式是使用fmt包的一系列打印函数,这同样可以用类似的方式添加到我们的项目中:

package util;

import java.util.Formatter;

public class Fmt {
    public static void printf(String format, Object... args) {
        System.out.printf(format, args);
    }

    public static String sprintf(String format, Object... args){
        return String.format(format, args);
    }

    public static void fprintf(Appendable appendable, String format, Object... args){
        Formatter formatter = new Formatter(appendable);
        formatter.format(format, args);
        formatter.close();
    }
}

然后就可以像Go中那样很容易地实现字符串格式化输出:

package ch3.pack6;

import java.io.FileWriter;
import java.io.IOException;

import util.Fmt;

public class Main {
    public static void main(String[] args) {
        String name = "icexmoon";
        int age = 16;
        Fmt.printf("My name is %s, my age is %d years old.\n", name, age);
        String msg = Fmt.sprintf("My name is %s, my age is %d years old.\n", name, age);
        System.out.println(msg);
        try {
            FileWriter fopen = new FileWriter("test.txt", true);
            Fmt.fprintf(fopen, "My name is %s, my age is %d years old.\n", name, age);
            fopen.close();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        // My name is icexmoon, my age is 16 years old.
        // My name is icexmoon, my age is 16 years old.
    }
}

实际上Formatter类的构造函数是重载的,支持多种方式的参数,比如可以直接接收字符串形式的文件名,我这里选择Appendable接口形式的参数,是因为这样和Go中的Writer接口更方式更类似,且比直接指定文件名的形式更具通用性。

访问权限修饰符

开发者应当对public、protected、private不陌生,它们都统称为“访问权限修饰符”,C/C++使用它们作为访问控制的手段,类C语言(如Java、PHP)也都广泛使用。

Python和Go比较独特,前者使用_xxx和__xxx这种命名方式来对外隐藏,后者通过首字母是否大小写来决定是否能被外部代码访问。

我们都知道,OOP有三个组成部分:封装、继承、多态。而访问修饰符就是OOP中实现封装的具体手段,所以它相当重要。

private

private权限很好理解,被它定义的属性和方法都只能在类内部进行访问。需要注意的是,这个类内部并非当前对象,而是只要在类定义中就可以,所以如果是两个同一个类的对象,其中一个是可以调用另一个的private属性和方法的:

package ch3.private1;

import java.util.Random;

import util.Fmt;

class Number {
    private int num;

    public Number(int num) {
        this.num = num;
    }

    @Override
    public String toString() {
        return Fmt.sprintf("Number(%d)", this.num);
    }

    public void add(Number otherNumber) {
        this.num += otherNumber.num;
    }

    public static Number add(Number num1, Number num2) {
        return new Number(num1.num + num2.num);
    }

}

public class Main {
    public static void main(String[] args) {
        Random random = new Random();
        Number num1 = new Number(random.nextInt(10));
        Number num2 = new Number(random.nextInt(10));
        Fmt.printf("num1:%s\n", num1);
        Fmt.printf("num2:%s\n", num2);
        num1.add(num2);
        Fmt.printf("num1:%s\n", num1);
        Fmt.printf("num1+num2=%s", Number.add(num1, num2));
        // num1:Number(8)
        // num2:Number(2)
        // num1:Number(10)
        // num1+num2=Number(12)
    }
}

public

public是和private相对的另一个极端,在任何地方都可以访问被声明为public的方法和属性。

包访问权限

“包访问权限”是Java所独有的,虽然诸如Go和Python同样存在包管理,但他们在访问控制上的做法与Java有很大差别。

在Java中,包访问权限是默认权限,也就是说,如果属性和方法没有指定访问修饰符,那它们的访问权限就是“包访问权限”。

包访问权限意味着,对于同一个包的代码而言,是可见的,类似于public权限。而对于包外的代码,则不可见,类似于private权限。这种做法可以很自然地将权限用包来进行划分,有利于开发一些独立的第三方包,在包内部可以随意地调用属性或方法,同时对外隔离,修改相应的代码也不会对客户端程序产生影响。

protected

有时候你可能希望某些类的属性和方法能够被包外代码“有限度使用”,这意味着即不是让包外代码完全不能访问,也不是让它们能够随意访问。这时候可以考虑将它们声明为protected。这种权限这意味着除了属性和方法所在的类本身和相应的包,包外部的代码也可以通过继承来使用。

换句话说,protected权限的范围是大于“包访问权限”,小于public权限的。

假设有这么一个类:

package ch3.my_class;

public class MyClass {
    protected int num;

    public MyClass(int num) {
        this.num = num;
    }

}

其num属性是protected,所以对包内是可见的:

package ch3.my_class;

public class Main {
    public static void main(String[] args) {
        MyClass mc = new MyClass(10);
        System.out.println(mc.num);
    }
}

但对于包外的代码是不可见的:

package ch3.other;

import ch3.my_class.MyClass;

public class Main {
    public static void main(String[] args) {
        MyClass mc = new MyClass(10);
        System.out.println(mc.num);
    }
}

不能通过编译,这里会显示is not visible。

调用构造函数创建对象是没问题的,因为类访问权限是public,且构造函数也是public。

但是可以通过继承的方式进行访问:

package ch3.other;

import ch3.my_class.MyClass;

public class MyChild extends MyClass {

    public MyChild(int num) {
        super(num);
        // TODO Auto-generated constructor stub
    }

    public static void main(String[] args) {
        MyChild mc = new MyChild(10);
        System.out.println(mc.num);
    }

}

这里的main因为是MyChild的静态方法,所以同样可以直接访问其protected属性。

总结和建议

在我接触Python和Go后,发现访问权限控制作为OOP封装的一环,和语言的结合是相当紧密的,而不同语言对于OOP的理解和实现方式的不同,就导致了在访问控制风格上有很大不同,进而导致相关代码风格的不同。

举例来说,Java更倾向于尽可能地将属性和方法声明为private权限,这是因为从private修改为public是简单的,但反过来是相当困难的,这可能需要对很多涉及的外部代码进行重构。这样就意味着你的代码没有经过良好封装。

但是对属性的访问又是切实的需求,所以Java广泛地使用访问器和修改器来“公开”私有属性:

package ch3.accessor;

public class MyClass {
    private int num;

    public int getNum() {
        return num;
    }

    public void setNum(int num) {
        this.num = num;
    }

    public static void main(String[] args) {
        MyClass mc = new MyClass();
        mc.setNum(10);
        System.out.println(mc.getNum());
    }

}

这样做就可以做到既让私有属性可以对外提供服务,又可以在必要时刻直接修改setter和getter来改变内部实现的同时不影响外部的客户端程序。

但这样就需要开发者大量编写getter和setter之类的代码,这样做枯燥又乏味,且降低了开发效率。万幸的是通过IDE可以自动创建getter和setter,所以现在看来这么做也可以接受。

而Python对此采取截然不同的方式,Python的风格是尽可能将属性"声明"为public的,这样可以很方便地进行访问,而不需要创建什么额外代码,这无疑可以提高编码效率。而对于后续修改可能引起的问题,Python提供了一种称作property的特性,利用给属性创建property,可以直接改变相应属性的读写行为,且外部代码无需做任何修改。

总的来说,你应当遵循当前编程语言的推荐方式来处理访问权限,而不是僵硬地“借鉴”另一种编程语言的风格。

此外,Java还推荐将类方法按访问权限来进行排列,也就是说按照public、protected、private的顺序排列,这样做有利于阅读源码,因为大多数情况下,使用者都只需要关心public方法。虽然可以利用一些工具将源码转化为类似javadoc的文档,文档会自动排列并展示方法,但是我认为让源码更具可读性依然是有意义的。

最后,总结一下,Java中几种访问权限从大到小的顺序是:public、protected、包访问权限、private。

类访问权限

这是个很奇怪的特性,很多编程语言并不会对类设置访问权限,但Java可以。

但Java中的类访问权限只有两种:public和包访问权限。这不难理解,如果类的访问权限是private,那就意味着该类无法被利用,也就没有任何存在价值。而如果类的访问权限是protected,如果包外代码想进行继承,因为类是protected,无法进行继承,而无法继承也就意味着protected没有意义。而对于包内代码,其效果完全和public以及包访问权限是等同的,也就是说protected权限对于类来说没有意义,无论是包内还是包外。

如果要类能够被包外代码使用,则要在类声明中添加public关键字:

package ch3.my_class;

public class MyClass {
	...
}

如果不希望包外代码使用,则不需要使用任何关键字:

package ch3.pro_class;

class MyClass {
    @Override
    public String toString() {
        return "MyClass()";
    }
}

此时就是包访问权限,只能被包内代码利用:

package ch3.pro_class;

public class Main {
    public static void main(String[] args) {
        System.out.println(new MyClass());
        // MyClass()
    }
}

包外的代码则无法通过编译:

package ch3.other;

import ch3.pro_class.MyClass;

public class Main2 {
    public static void main(String[] args) {
        MyClass mc = new MyClass();
    }
}

谢谢阅读。

参考资料

  • Java import static静态导入 (biancheng.net)
  • FileWriter (Java Platform SE 8 ) (oracle.com)