• 初识Java 18-1 泛型


    目录

    简单泛型

    元组库

    通过泛型实现栈类

    泛型接口

    泛型方法

    可变参数和泛型方法

    通用Supplier

    简化元组的使用

    使用Set创建实用工具


    本笔记参考自: 《On Java 中文版》


            继承的层次结构有时会带来过多的限制,例如:编写的方法或类往往要依赖于具体的类型。尽管接口突破了单一继承层次结构,但我们可能会想要更加“泛用”的代码,这也是面向对象编程的目的之一。

            为了让代码不再依赖于特定的接口与类,Java 5引入了泛型的概念。

    ||| 在术语中,“泛型” 是指“适用或者可以兼容大批的”。

            泛型可以生成参数化类型,以此来支持适用于多种类型的组件。当我们创建了某个参数化类型的实例时,类型转换会自动发生,并且在编译期间确保类型的正确性

        然而,Java在正式引入泛型之前,已经有了近十年的历史,这意味着在此期间无数工作者创建并使用的库并不会涉及泛型。为了兼容这些旧的库与程序,Java的设计者不得不在Java的泛型上“走些远路”。因此,Java的泛型可能不会如同其他一些语言来得好用。

    简单泛型

            泛型设计的目的之一,就是用于创建集合类。集合比起数组要更加灵活,并且会具备不同的特性(实际上,集合也是复用性最高的库之一)

            假设有一个类,它持有一个简单的对象,具有简单的操作:

    【例子:简单的类】

    1. class Automobile {
    2. }
    3. public class Holder1 {
    4. private Automobile a;
    5. public Holder1(Automobile a) {
    6. this.a = a;
    7. }
    8. Automobile get() {
    9. return a;
    10. }
    11. }

            对于这个类而言,具体的对象限制了对它的复用。如果这个类表示着某个功能模块,我们可能就需要为每一个模块写一份相似的代码。

            在Java 5之前,如果要解决这个问题,我们可以使用Object对象:

    【例子:使用Object对象创建通用的类】

    1. public class ObjectHolder {
    2. private Object a;
    3. private ObjectHolder(Object a) {
    4. this.a = a;
    5. }
    6. public void set(Object a) {
    7. this.a = a;
    8. }
    9. public Object get() {
    10. return a;
    11. }
    12. public static void main(String[] args) {
    13. ObjectHolder h2 =
    14. new ObjectHolder(new Automobile());
    15. Automobile a = (Automobile) h2.get();
    16. h2.set("传入不是Automobile的类型(字符串)");
    17. String s = (String) h2.get();
    18. h2.set(1); // 发生自动装箱
    19. Integer x = (Integer) h2.get();
    20. }
    21. }

            通过这种方式,ObjectHolder类实现了对不同类型对象的持有。但这依旧不够“具体”,尽管通过一个集合持有许多不同的对象在一些时候会有用,但更多时候,我们会将具体类型的对象放入特定的集合中

             泛型的一个目的就是指定集合能够持有的对象类型,并且通过编译器强制执行这一规范:

    【例子:使用泛型创建简单的类】

    1. public class GenericHolder {
    2. private T a;
    3. public GenericHolder(){
    4. }
    5. public void set(T a){
    6. this.a = a;
    7. }
    8. public T get(){
    9. return a;
    10. }
    11. public static void main(String[] args) {
    12. GenericHolder h3 =
    13. new GenericHolder<>(); // 钻石语法
    14. h3.set(new Automobile()); // 在编译时,会检测类型
    15. Automobile a = h3.get();
    16. // h3.set("类型不对,不允许输入");
    17. // h3.set(1);
    18. }
    19. }

            这里第一次正式提到了泛型语法:

    public class GenericHolder {

    此处的【T】被称为类型参数,在此处作为一个类型占位符使用。在使用时,【T】会被替换为具体的类型。

            从main()中可以看到,泛型需要在尖括号语法中定义其要存储的类型。通过这种方式,我们可以强制 h3 只存储指定类或其的子类。

        这里也体现了Java中泛型的核心理念:只需告诉泛型所需的类型,剩下的细节由编译器来处理。

            一个好的理解方式是,将泛型视同其他的类型,只是泛型恰好有类型参数而已。

    元组库

            有时,我们会希望能够从方法中返回多个对象。一般,我们需要通过一个特殊的类(集合)来实现这一功能。但这种实现往往会受到具体类型的限制。因此,在这里使用泛型是一个不错的选择(同时,我们也可以享受到泛型带来的编译时检查)

            通过泛型打包多个对象,这一概念的实现就是元组(或称数据传输对象、信使)。这种对象有一个限制,它只能读取,不能写入

            元组一般不会设置长度限制,且允许每一个对象是不同的类型。但为了接收方便,我们仍会指定元素的类型:

    【例子:一个持有两个对象的元组】

    1. public class Tuple2 {
    2. public final A a1;
    3. public final B b1;
    4. public Tuple2(A a, B b) {
    5. a1 = a;
    6. b1 = b;
    7. }
    8. public String rep() {
    9. return a1 + "," + b1;
    10. }
    11. @Override
    12. public String toString() {
    13. return "(" + rep() + ")";
    14. }
    15. }

            类型参数的不同使得元组可以隐式地按序存储数据。

            首先分析一下两个final对象:

    1. public final A a1;
    2. public final B b1;

    尽管这两个对象是public的,但final关键字保证了它们不会在初始化后被再次更改。若强行赋值,会看到如下报错:

    同时,这种写法也允许外部读取这两个对象。比起使用get()方法而言,这种方法在提供了与private等价的安全性的同时,也更加简洁。

        并不建议对a1和b1进行重新赋值。若需要,那么更好的方法是创建一个新的元组。

            我们也可以在现有元组的基础上创建一个更长的元组。通过继承,可以轻易做到:

    【例子:更长的元组】

    1. public class Tuple3 extends Tuple2 {
    2. public final C c3;
    3. public Tuple3(A a, B b, C c) {
    4. super(a, b);
    5. c3 = c;
    6. }
    7. @Override
    8. public String rep() {
    9. return super.rep() + "," + c3;
    10. }
    11. }

            乃至于更长的元组,这里不展示更多了:

            接下来就可以尝试使用元组了:

    【例子:使用元组】

            先定义一些类,用以放入元组中:

    public class Amphibian {}
    public class Vehicle {}

             然后就是使用元组了:

    1. import onjava.Tuple2;
    2. import onjava.Tuple3;
    3. public class TupleTest {
    4. static Tuple2 f() {
    5. // 会发生自动装箱(int -> Integer)
    6. return new Tuple2<>("Hi", 123);
    7. }
    8. static Tuple3 g() {
    9. return new Tuple3<>(new Amphibian(), new Vehicle(), 321);
    10. }
    11. public static void main(String[] args) {
    12. // 当接受时,需要设置好对应的元组元素
    13. Tuple2 t1 = f();
    14. System.out.println(f());
    15. System.out.println(g());
    16. }
    17. }

            程序执行的结果是:

            通过泛型,可以轻松地使方法返回一组对象。


    通过泛型实现栈类

            Java中的栈(Stack)主要由两部分组成:泛型类StackLinkedList。栈的结构较为简单,而通过泛型,我们也可以独立实现自己的栈类:

    【例子:实现一个栈类】

    1. public class LinkedStack {
    2. // 使用内部类实现结点(结点同样是一个泛型):
    3. private static class Node {
    4. U item;
    5. Node next;
    6. Node() {
    7. item = null;
    8. next = null;
    9. }
    10. Node(U item, Node next) {
    11. this.item = item;
    12. this.next = next;
    13. }
    14. boolean end() {
    15. return item == null &&
    16. next == null;
    17. }
    18. }
    19. // 设置空的结点头(也被称为末端哨兵)
    20. private Node top = new Node<>();
    21. public void push(T item) {
    22. // 在创建新结点的同时完成结点之间的链接
    23. // top指向栈顶元素
    24. top = new Node<>(item, top);
    25. }
    26. public T pop() {
    27. T result = top.item;
    28. if (!top.end())
    29. top = top.next; // top向下移动
    30. return result;
    31. }
    32. public static void main(String[] args) {
    33. LinkedStack lss = new LinkedStack<>();
    34. for (String s : "第一 第二 第三".split(" "))
    35. lss.push(s);
    36. String s;
    37. while ((s = lss.pop()) != null)
    38. System.out.println(s);
    39. }
    40. }

            程序执行的结果是:

            top哨兵)的存在,使得我们可以在栈上进行移动,并完成各种操作。

    泛型接口

            接口的参数同样可以是泛型。java.util.function.Supplier就是一个典型的例子:

    实际上,这一接口同时还是Java定义的生成器,其中的生成方法是T get(),能够根据实现生成一个新的对象。

        生成器设计模式来自于工厂方法设计模式,它们都用于创建对象。不同的地方在于,生成器不需要传入参数(即不需要额外信息)来生成对象。

            这是一个创建Supplier的例子:

    【例子:实现Supplier

            先设计一个简单的继承结构,首先确定基类Coffee:

    1. public class Coffee {
    2. private static long counter = 0;
    3. private final long id = counter++;
    4. @Override
    5. public String toString() {
    6. return getClass().getSimpleName() + " " + id;
    7. }
    8. }

            之后我们需要的只是打印对象,因此子类只需要其名称即可。

            然后是Supplier的实现:

    1. import generics.coffee.*;
    2. import java.lang.reflect.InvocationTargetException;
    3. import java.util.Iterator;
    4. import java.util.Random;
    5. import java.util.function.Supplier;
    6. import java.util.stream.Stream;
    7. public class CoffeeSupplier
    8. implements Supplier, Iterable {
    9. private Class[] types = {Latte.class, Mocha.class,
    10. Cappuccino.class, Americano.class, Breve.class};
    11. private static Random random = new Random(47);
    12. public CoffeeSupplier() {
    13. }
    14. private int size = 0;
    15. public CoffeeSupplier(int sz) {
    16. size = sz;
    17. }
    18. @Override
    19. public Coffee get() {
    20. try {
    21. return (Coffee) types[random.nextInt(types.length)]
    22. .getConstructor().newInstance();
    23. } catch (InstantiationException |
    24. NoSuchMethodException |
    25. InvocationTargetException |
    26. IllegalAccessException e) {
    27. throw new RuntimeException(e);
    28. }
    29. }
    30. class CoffeeIterator implements Iterator {
    31. int count = size;
    32. @Override
    33. public boolean hasNext() {
    34. return count > 0;
    35. }
    36. @Override
    37. public Coffee next() {
    38. count--;
    39. return CoffeeSupplier.this.get();
    40. }
    41. @Override
    42. public void remove() { //该方法未实现,因此返回异常
    43. throw new UnsupportedOperationException();
    44. }
    45. }
    46. @Override
    47. public Iterator iterator() {
    48. return new CoffeeIterator();
    49. }
    50. public static void main(String[] args) {
    51. Stream.generate(new CoffeeSupplier())
    52. .limit(5)
    53. .forEach(System.out::println);
    54. //由于实现了Iterable接口
    55. // 因此可以将CoffeeSupplier用于for-in语句
    56. for (Coffee c : new CoffeeSupplier(5))
    57. System.out.println(c);
    58. }
    59. }

            程序执行的结果是:

     ---

            再看一个例子,使用Supplier生成斐波那契数列:

    【例子:生成斐波那契数列】

    1. import java.util.function.Supplier;
    2. import java.util.stream.Stream;
    3. public class Fibonacci implements Supplier {
    4. private int count = 0;
    5. @Override
    6. public Integer get() {
    7. return fib(count++);
    8. }
    9. private int fib(int n) {
    10. if (n < 2) return 1;
    11. return fib(n - 2) + fib(n - 1);
    12. }
    13. public static void main(String[] args) {
    14. Stream.generate(new Fibonacci())
    15. .limit(18)
    16. .map(n -> n + " ")
    17. .forEach(System.out::print);
    18. }
    19. }

            程序执行的结果是:

            这里需要注意的是类型参数:

    这里使用的类型参数是Integer,而在类内部我们只使用了int类型。之所以需要使用包装类来规定类型参数,是因为泛型不允许将基本类型作为类型参数(涉及到类型擦除)

    【扩展】

            进一步地,若我们想要实现一个可以迭代(Iterable)的斐波那契数列,有两个方法:

    • 一:改装原有的类,并添加Iterable接口。这一方法有一个缺点:我们并不总是能够获得原始代码(或者代码的控制权)。

    因此这里选择使用第二个方法:

    • 二:使用适配器生成所需的接口。

    【例子:使用继承生成适配器】

    1. import java.util.Iterator;
    2. public class IterableFibonacci extends Fibonacci
    3. implements Iterable {
    4. private int n;
    5. public IterableFibonacci(int count) {
    6. n = count;
    7. }
    8. @Override
    9. public Iterator iterator() {
    10. return new Iterator() {
    11. @Override
    12. public boolean hasNext() {
    13. // 需要设置这样的一个n(边界),用于判断是否返回false
    14. return n > 0;
    15. }
    16. @Override
    17. public Integer next() {
    18. n--;
    19. return IterableFibonacci.this.get();
    20. }
    21. @Override
    22. public void remove() {//未实现,返回异常
    23. throw new UnsupportedOperationException();
    24. }
    25. };
    26. }
    27. public static void main(String[] args) {
    28. for (int i : new IterableFibonacci(18))
    29. System.out.print(i + " ");
    30. }
    31. }

            程序执行的结果是:

    泛型方法

            除了对整个类进行泛型化外,还可以对单个的方法使用泛型化,这就成了泛型方法

            泛型方法的行为会随着类型参数的改变而变化,并且不受类的影响。一般情况下,泛型方法会更加方便,因为相比于整个类,单一方法的泛型化往往更加清晰

        因此,可以“尽量”使用泛型方法

            除此之外,若某个方法是静态的,那么它将无法访问类的泛型类型参数。此时,若方法需要使用到泛型,那么该方法就必须被设置为泛型方法。

            下面的例子展示了定义泛型方法的方式:

    【例子:定义泛型方法】

    1. public class GenericMethods {
    2. // 泛型参数列表(即)需要放在返回值之前
    3. public void f(T x) {
    4. System.out.println(x.getClass().getName());
    5. }
    6. public static void main(String[] args) {
    7. GenericMethods gm = new GenericMethods();
    8. gm.f("");
    9. gm.f(1);
    10. gm.f(1.0);
    11. gm.f(1.0F);
    12. gm.f('c');
    13. gm.f(gm);
    14. }
    15. }

            程序执行的结果是:

            就如同例子中的f()方法一样:

    必须在返回值前设置表示的类型参数列表。除此之外,泛型方法和泛型类的使用还有一个区别:泛型类在实例化时必须指定类型参数,而泛型方法不用,编译器会处理好一切。这就是类型参数推断


    可变参数和泛型方法

            泛型方法也兼容可变参数列表

    【例子:包含可变参数列表的泛型方法】

    1. import java.util.ArrayList;
    2. import java.util.List;
    3. public class GenericVarargs {
    4. @SafeVarargs
    5. public static List makeList(T... args) {
    6. List result = new ArrayList<>();
    7. for (T item : args)
    8. result.add(item);
    9. return result;
    10. }
    11. public static void main(String[] args) {
    12. List ls = makeList("A");
    13. System.out.println(ls);
    14. ls = makeList("A", "B", "C");
    15. System.out.println(ls);
    16. ls = makeList("ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    17. .split(""));
    18. System.out.println(ls);
    19. }
    20. }

            程序执行的结果是:

            此处的@SafeVarargs注解表示,我们向系统承诺不会对变量参数列表进行任何修改(实际上我们也没有做任何修改)。若没有这个注解,编译器就会产生警告。

    (警告会在编译时产生,但依旧可以通过编译并产生.class文件)


    通用Supplier

            可以提供泛型创建更通用的Supplier。下面的例子可以为任何一个具有无参构造器的类生成一个Supplier

    1. import java.lang.reflect.InvocationTargetException;
    2. import java.util.function.Supplier;
    3. public class BasicSupplier implements Supplier {
    4. private Class type;
    5. public BasicSupplier(Class type) {
    6. this.type = type;
    7. }
    8. @Override
    9. public T get() {
    10. try {
    11. // newInstance()只对public的类有效
    12. return type.getConstructor().newInstance();
    13. } catch (InstantiationException |
    14. NoSuchMethodException |
    15. InvocationTargetException |
    16. IllegalAccessException e) {
    17. throw new RuntimeException(e);
    18. }
    19. }
    20. // 根据类型标记(token)返回一个默认的Supplier
    21. public static Supplier create(Class type) {
    22. return new BasicSupplier<>(type);
    23. }
    24. }

            若一个类符合以下条件,则可以通过上述代码创建其对象的基本实现:

    • 该类是public的。
    • 该类具有无参构造器。

            静态方法create()具有独立的类型参数。通过这个方法,可以方便地创建一个BasicSupplier对象。下面是BasicSupplier类的使用例:

    【例子:BasicSupplier的使用例】

            为了展示BasicSupplier的功能,先创建一个简单的类:

    1. public class CountedObject {
    2. private static long counter = 0;
    3. private final long id = counter++;
    4. public long id() {
    5. return id;
    6. }
    7. @Override
    8. public String toString() {
    9. return "CountedObject " + id;
    10. }
    11. }

            现在可以通过BasicSupplierCountedObject创建Supplier

    1. import java.util.stream.Stream;
    2. public class BasicSupplierDemo {
    3. public static void main(String[] args) {
    4. Stream.generate(
    5. BasicSupplier.create(CountedObject.class))
    6. .limit(5)
    7. .forEach(System.out::println);
    8. }
    9. }

            程序执行的结果是:

            这么做有两个好处:

    1. 减少了我们生成Supplier对象所需的代码编写量。
    2. 创建BasicSupplier时,泛型强制要求传入Class对象,也为create()方法提供了类型判断。

    简化元组的使用

            通过静态导入(static)和类型参数推断,就可以整合之前创建的元组:

    【例子:更通用的元组】

    1. public class Tuple {
    2. public static Tuple2 tuple(A a, B b) {
    3. return new Tuple2<>(a, b);
    4. }
    5. public static Tuple3 tuple(A a, B b, C c) {
    6. return new Tuple3<>(a, b, c);
    7. }
    8. public static Tuple4
    9. tuple(A a, B b, C c, D d) {
    10. return new Tuple4<>(a, b, c, d);
    11. }
    12. public static Tuple5
    13. tuple(A a, B b, C c, D d, E e) {
    14. return new Tuple5<>(a, b, c, d, e);
    15. }
    16. }

            可以用于之前类似的方式测试这个新类:

    【例子:测试新的元组】

    1. import onjava.Tuple2;
    2. import onjava.Tuple3;
    3. import onjava.Tuple4;
    4. import onjava.Tuple5;
    5. // 静态导入Tuple:
    6. import static onjava.Tuple.*;
    7. public class TupleTest2 {
    8. static Tuple2 f() {
    9. return tuple("Hello", 47);
    10. }
    11. static Tuple2 f2() {
    12. return tuple("Hello", 47);
    13. }
    14. static Tuple3 g() {
    15. return tuple(new Amphibian(), "Hello", 47);
    16. }
    17. static Tuple4 h() {
    18. return tuple(
    19. new Vehicle(), new Amphibian(), "Hello", 47);
    20. }
    21. static Tuple5
    22. String, Integer, Double> k() {
    23. return tuple(
    24. new Vehicle(), new Amphibian(),
    25. "Hello", 47, 11.1);
    26. }
    27. public static void main(String[] args) {
    28. Tuple2 ttsi = f();
    29. System.out.println(ttsi);
    30. System.out.println(f2());
    31. System.out.println(g());
    32. System.out.println(h());
    33. System.out.println(k());
    34. }
    35. }

            程序执行的结果是:

            这里需要注意的是f2(),它返回了一个未参数化的Tuple2对象。尽管如此,但由于我们并未试图获取f2()的结果(并将其放入参数化的Tuple2中),因此编译器没有发出警告。


    使用Set创建实用工具

            可以利用Set创建一系列表示数学关系的方法:

    1. import java.util.HashSet;
    2. import java.util.Set;
    3. public class Sets {
    4. // 合并a、b(取并集)
    5. public static Set union(Set a, Set b) {
    6. Set result = new HashSet<>(a);
    7. result.addAll(b);
    8. return result;
    9. }
    10. // 取a、b中都存在的元素(取交集)
    11. public static
    12. Set intersection(Set a, Set b) {
    13. Set result = new HashSet<>(a);
    14. result.retainAll(b);
    15. return result;
    16. }
    17. // 从超集中减去子集:
    18. public static Set
    19. difference(Set superset, Set subset) {
    20. Set result = new HashSet<>(superset);
    21. result.removeAll(subset);
    22. return result;
    23. }
    24. // 获取所有不在交集中的元素
    25. public static Set
    26. complement(Set a, Set b) {
    27. return difference(union(a, b), intersection(a, b));
    28. }
    29. }

            通过将数据复制到一个新的HashSet中,我们可以保证进行的修改不会影响原本的数据。

            接下来就可以使用这些Set工具了。为此,我们还需要先创建一些用于存储的枚举:

    【例子:创建枚举】

    1. package generics.watercolors;
    2. public enum Watercolors {
    3. RED, GREEN, BLUE, YELLOW, ORANGE,
    4. PURPLE, CYAN, MAGENTA, WHITE, BLACK
    5. }

            接下来就可以使用这个枚举类型展示Set工具的用法了:

    【例子:使用创建的Set工具】

    1. import generics.watercolors.Watercolors;
    2. import static generics.watercolors.Watercolors.*;
    3. import java.util.EnumSet;
    4. import java.util.Set;
    5. import static onjava.Sets.*;
    6. public class WatercolorSets {
    7. public static void main(String[] args) {
    8. Set set1 =
    9. EnumSet.range(RED, MAGENTA);
    10. Set set2 =
    11. EnumSet.range(BLUE, BLACK);
    12. System.out.println("set1: " + set1);
    13. System.out.println("set2: " + set2);
    14. System.out.println("union(set1, set2): " +
    15. union(set1, set2));
    16. Set subset = intersection(set1, set2);
    17. System.out.println("intersection(set1, set2): " +
    18. subset);
    19. System.out.println("difference(set1, subset): " +
    20. difference(set1, subset));
    21. System.out.println("difference(set2, subset): " +
    22. difference(set2, subset));
    23. System.out.println("complement(set1, set2): " +
    24. complement(set1, set2));
    25. }
    26. }

            程序执行的结果是:

            还可以用这些工具比较不同Collection之间的区别:

    【例子:不同Collection之间的区别】

    1. import onjava.Sets;
    2. import java.lang.reflect.Method;
    3. import java.util.*;
    4. import java.util.stream.Collectors;
    5. public class CollectionMethodDifferences {
    6. static Set methodSet(Class type) {
    7. return Arrays.stream(type.getMethods())
    8. .map(Method::getName)
    9. .collect(Collectors.toCollection(TreeSet::new));
    10. }
    11. static void interfaces(Class type) {
    12. System.out.print("【" + type.getSimpleName() +
    13. "】继承了的接口:");
    14. System.out.println(Arrays.stream(type.getInterfaces())
    15. .map(Class::getSimpleName)
    16. .collect(Collectors.toList()));
    17. }
    18. static Set object =
    19. methodSet(Object.class);
    20. static {
    21. object.add("加载Object的方法");
    22. }
    23. static void
    24. difference(Class superset, Class subset) {
    25. System.out.print("【" + superset.getSimpleName() +
    26. "】继承自【" + subset.getSimpleName() +
    27. "】,并添加了这些方法:");
    28. Set comp = Sets.difference(methodSet(superset),
    29. methodSet(subset));
    30. comp.removeAll(object); // 忽略所有Object类中的方法
    31. System.out.format(comp + "%n");
    32. interfaces(superset);
    33. }
    34. // 方便打印
    35. static void
    36. printDifference(Class superset, Class subset) {
    37. System.out.println();
    38. difference(superset, subset);
    39. }
    40. public static void main(String[] args) {
    41. System.out.println("【Collection】中的方法有:" +
    42. methodSet(Collection.class));
    43. interfaces(Collection.class);
    44. printDifference(Set.class, Collection.class);
    45. printDifference(HashSet.class, Set.class);
    46. printDifference(LinkedHashSet.class, HashSet.class);
    47. }
    48. }

            程序执行的结果是:

    (也可以用于查看Map之间的区别。因为集合类较多,这里不一一展示。)

  • 相关阅读:
    在 Windows 中使用 System Settings
    基于nodejs的阿里云DDNS服务,支持多网卡绑定
    React 组件性能优化
    查询截取分析_慢查询日志
    迅为IMX8M开发板安装VMware Tool工具
    导入sklearn报错:No module named ‘threadpoolctl‘
    《Photoshop 2020从入门到精通》读书笔记1
    grafana+prometheus+loki的使用
    插件化编程之WebAPI统一返回模型
    ubuntu终端代码上传github最简方法
  • 原文地址:https://blog.csdn.net/w_pab/article/details/134362630