首页 > 其他分享 >不可变(immutable)设计原则

不可变(immutable)设计原则

时间:2024-05-16 16:18:52浏览次数:26  
标签:return 迭代 原则 int list 类型 设计 immutable String

我们先从 Java 的数据类型与类型检查说起

数据类型

数据类型是什么?

数据类型是一组以及可以对其执行的操作,比如:

- boolean: Truth value(true or false)
- int: Integer(0, 1, -47)
- double: Real number(3.14, 1.0, -2.1)
- String: Text("Hello", "example")

而变量就是用特定数据类型定义、可存储满足类型约束的值

Java 中的数据类型

Java 的数据类型分为基本数据类型对象数据类型

两者组成及区别如下表所示:

类型检查

既然有了不同数据类型的区分,那么对不同数据类型的变量赋值就要遵照相应的规则,否则就会报错

Java 中的变量在不同数据类型的转换分为两种:隐式转换、显式(强制)转换。比如下面就是几个简单直观的例子:

int a = 2;		// a = 2
double a = 2;		// a = 2.0 (Implict)
int a = (int)18.7	// a = 18
double a = (double)2/3;	// a = 0.6666...

int a = 18.7;		// ERROR
String a = 1;		// ERROR
double a = 2/3;		// a = 0.0

接下来,我将详细阐述各种类型转换的规则:

隐式类型转换

Java 中的简单数据类型由低级到高级可以分为:

byte=short=char < int < long < float < double

低级变量可以直接隐式转换为高级变量:

byte b = 1;		
int i = b;	// i = 1
long l = b;	// l = 1
float f = b;	// f = 1.0
double d = b;	// d = 1.0

将 char 型向更高级类型转换时,会转换为对应 ASCII 码,例如:

char c = 'a';
int  i = c;	// i = 97

强制类型转换

将高级类型转换为低级类型时,需要强制类型转换。强制类型转换有副作用,可能带来溢出或精度损失等

long l = 2147483648L;
int i = (int)l;		// i = -2147483648, 向上溢出

double d = 2.70141215;	// d = 2.70141215
float f = (float) d;	// f = 2.7014122, 精度损失

静态检查与动态检查

当类型不匹配时,编译器通过静态检查检查出错误

Java 是一种静态类型语言,所有变量的类型在编译时已知,因此编译器可以推导表达式类型,可在编译阶段发现错误,避免了将错误
带入到运行阶段,提高程序正确性/健壮性

而动态类型只能在运行阶段进行类型检查

静态检查:

  • 语法错误
  • 类型/函数名错误:比如 Math.sine(2)
  • 参数数目错误:比如 Math.sin(30, 20)
  • 参数类型错误:比如 Math.sin("30")
  • 返回值类型错误

动态检查:

  • 非法的参数值:比如 x/y when y = 0
  • 非法的返回值
  • 越界
  • 空指针

说白了,静态检查就是关于类型的检查,不考虑值;而动态检查就是关于值的检查

举一些例子:

静态错误(参数类型错误):

/*
Type mismatch:
cannot convert
from int to
boolean
*/
int n = 5;
if(n) {
    n = n + 1;
}

动态错误(非法的参数值):

/*
Exception in
thread "main"
java.lang.Arith
meticException:
/ by zero
*/
int sum = 0;
int n = 0;
int average = sum/n;

Mutability & Immutability

前两部分我们理清了类型的相关概念,接下来阐述对应类型的变量的概念

改变一个变量和改变一个变量的值,二者有何区别?

  • 改变一个变量是指将该变量指向一个存储空间
  • 改变一个变量的值是指将该变量当前指向的存储空间中写入一个新的值

由此,就能引出这一节的概念区分:

可变(mutable):类的实例中的引用或基本类型变量值可以被改变

不可变(immutable):类的实例中的引用或基本类型变量值不可被改变,使用final实现:

final int n = 5;
final Person a = new Person("Ross");

final 关键字有如下作用:

  • final 类无法派生子类
  • final 变量无法改变值/引用
  • final 方法无法被子类重写

比如,String 就是 immutable

String s = "a";
s += b;

StringBuilder 是 mutable

StringBuilder sb = new StringBuilder("a");
sb.append("b");

根据上面的两个例子,immutable type 的缺点很明显:

  • 对 immutable type 的频繁修改会产生大量的临时拷贝,需要垃圾回收

因此,在对字符串包装类 String 进行大量修改时,IDEA 会推荐我们使用 StringBuilder

根据上述分析,mutable type 的性能要更好,那么 immutable type 有什么用呢?

考虑如下例子:

设计一个求数组绝对值和的函数:

/**
 * @return the sum of the numbers in the list
 */
public static int sum(List<Integer> list) {
    int sum = 0;
    for (int x : list) {
        sum += x;
    }
    return sum;
}
/**
 * @return the sum of the absolute values of the numbers in the list
 */
public static int sumAbsolute(List<Integer> list) {
    // let's reuse sum(), because DRY, so first we take absolute values
    for (int i = 0; i < list.size(); ++i) {
        list.set(i, Math.abs(list.get(i)));
    }
    return sum(list);
}

最后结果是对的,但是在求解过程中,传入的初始数组内容也会被修改!

为了规避这种风险,有一个解决策略称为防御式拷贝

当对象需要暴露给外界时,使用其构造方法等手段将其拷贝,确保外界无法获取该对象及其内部各对象的引用。

不可变类型就不需要防御式拷贝,但是它的性能更差。因而,选择可变类型还是不可变类型需要综合考虑。

还有更复杂的例子,为了方便我们的分析,先引入工具:snapshot diagram

Snapshot diagram

snapshot diagram 用于描述一个程序在运行时某一时刻的内部状态,包括栈和堆。即一个程序设计过程中的图中标绿的部分:

它的作用为:

  • 便于设计者之间的交流
  • 刻画各类变量随时间变化
  • 解释思路

画法规定

  • 基本类型变量:一个箭头指向该变量的值。

  • 引用类型变量:一个箭头指向一个椭圆,椭圆内部包含类名。若想显示更多信息,可在椭圆内写入字段名,并添加箭头以确定其值。

  • 可变值:当变量值变化时,在原来的箭头(值)上打叉并画新的箭头(值)。

  • 不可变值(final):使用双线箭头表示。

举例

考虑如下代码:

String s1 = new String("abc");
List<String> list = new ArrayList<String>();
list.add(s1);

s1 = s1.concat("d");
String s2 = s1.concat("e");

list.set(0, s2);

画图如下:

为什么不使用可变类型?

再次回到我们的问题

下面我先以迭代一个可变对象为例,阐述 mutable type 的风险

举例:迭代

迭代器是一个对象,它会遍历一个聚合类型的对象,并逐个返回其中的元素。当在 Java 中使用 for(... : ...) 这样的语句遍历元素的循环时,其实就隐式地使用了迭代器。例如:

List<String> lst = ...;
for (String str : lst) {
    System.out.println(str);
}

会被编译器理解为下面这样:

List<String> lst = ...;
Iterator<String> iter = lst.iterator();
while (iter.hasNext()) {
    String str = iter.next();
    System.out.println(str);
}

迭代器有两个方法:

  • next() 返回对象的下一个元素
  • hasNext() 判断迭代器是否已经遍历到对象的结尾

注意到 next() 是一个会修改迭代器的方法(mutator method),它不仅会返回一个元素,而且会改变内部状态,使得下一次使用它的时候会返回下一个元素。

MyIterator

为了更好理解迭代器的工作原理,我们可以手写一个迭代 List<String> 的迭代器:

/**
 * A MyIterator is a mutable object that iterates over
 * the elements of a List<String>, from first to last.
 * This is just an example to show how an iterator works.
 * In practice, you should use the List's own iterator
 * object, returned by its iterator() method.
 */
public class MyIterator {

    private final List<String> list;
    private int index;
    // list[index] is the next element that will be returned
    //   by next()
    // index == list.size() means no more elements to return

    /**
     * Make an iterator.
     * @param list list to iterate over
     */
    public MyIterator(List<String> list) {
        this.list = list;
        this.index = 0;
    }

    /**
     * Test whether the iterator has more elements to return.
     * @return true if next() will return another element,
     *         false if all elements have been returned
     */
    public boolean hasNext() {
        return index < list.size();
    }

    /**
     * Get the next element of the list.
     * Requires: hasNext() returns true.
     * Modifies: this iterator to advance it to the element 
     *           following the returned element.
     * @return next element of the list
     */
    public String next() {
        final String element = list.get(index);
        ++index;
        return element;
    }
}

可变性对迭代器的影响

考虑如下例子:

假设有一个 MIT 的课程代号列表,例如 ["6.031", "8.03", "9.00"] ,我们想要设计一个 dropCourse6 方法,它会将列表中所有以“6.”开头的代号删除,规约如下:

/**
 * Drop all subjects that are from Course 6. 
 * Modifies subjects list by removing subjects that start with "6."
 * 
 * @param subjects list of MIT subject numbers
 */
public static void dropCourse6(ArrayList<String> subjects)

利用迭代器,很容易就能写出代码:

public static void dropCourse6(ArrayList<String> subjects) {
    MyIterator iter = new MyIterator(subjects);
    while (iter.hasNext()) {
        String subject = iter.next();
        if (subject.startsWith("6.")) {
            subjects.remove(subject);
        }
    }
}

接下来测试运行一下:dropCourse6(["6.045", "6.005", "6.813"])

我们期望的结果肯定是全部被删除,但是真实输出却是:["6.005"]

这是为什么呢?我们可以用刚才学到的工具:snapshot 分析原因

快照图分析

  • 首先,迭代器的 index=0,判断第一个元素以 "6." 开头,于是把它删除

  • 此时,"6.005"ArrayList<String> 中的索引会变成 0,"6.813" 的索引会变成 1。而迭代器中的 index=1

因此,在下一轮迭代删除时,删除的是原来的第 3 个元素,而不是第 2 个,程序出错了!

为什么出错?

这里造成错误的原因并不是迭代器,而是 ArrayListArrayList 是一个可变类型,而我们在使用迭代器迭代的过程中却将其修改了,这必然会带来错误。

针对本问题,当然也有解决办法,就是使用迭代器的 remove() 方法,这样迭代器就能自动调整迭代索引

Iterator iter = subjects.iterator();
while (iter.hasNext()) {
    String subject = iter.next();
    if (subject.startsWith("6.")) {
        iter.remove();
    }
}

但是,这个解决方案并不完美,假设又另一个迭代器并行地对同一一个列表迭代并修改,那么迭代器就无法知道迭代对象发生了哪些变化。

因此,对一个可变类型使用迭代器总是有风险的!

降低代码的可改动性

举例:

下面这个方法在 MIT 的数据库中查找并返回用户的 9 位数 ID:

/**
 * @param username username of person to look up
 * @return the 9-digit MIT identifier for username.
 * @throws NoSuchUserException if nobody with username is in MIT's database
 */
public static char[] getMitId(String username) throws NoSuchUserException {        
    // ... look up username in MIT's database and return the 9-digit ID
}

假设有一个使用者:

char[] id = getMitId("bitdiddle");
System.out.println(id);

现在使用者和实现者都打算做一些改变: 使用者觉得要照顾用户的隐私,所以他只输出后四位 ID:

char[] id = getMitId("bitdiddle");
for (int i = 0; i < 5; ++i) {
    id[i] = '*';
}
System.out.println(id);

而实现者担心查找的性能,所以它引入了一个缓存记录已经被查找过的用户:

private static Map<String, char[]> cache = new HashMap<String, char[]>();

public static char[] getMitId(String username) throws NoSuchUserException {        
    // see if it's in the cache already
    if (cache.containsKey(username)) {
        return cache.get(username);
    }

    // ... look up username in MIT's database ...

    // store it in the cache for future lookups
    cache.put(username, id);
    return id;
}

这两个改变导致了一个隐秘的bug,当使用者查找 "bitdiddle" 并得到一个字符数组后,实现者也缓存的是这个数组,他们两个实际上索引的是同一个数组(别名)。这意味着用户用来保护隐私的代码会修改掉实现者的缓存,所以未来调用 getMitId("bitdiddle") 并不会返回一个九位数,例如 "928432033",而是修改后的 "*****2033",如图:

使用不可变类型

由前面的分析,可变类型的风险非常大,既然不可变类型避开了许多风险,我们就列出几个 Java API 中常用的不可变类型:

  • 所有的原始类型及其包装都是不可变的。例如使用 BigIntegerBigDecimal 进行大整数运算。
  • 不使用可变类型 Date ,而是使用 java.time 中的不可变类型。
  • Java中常见的聚合类 — List, Set, Map — 都是可变的:ArrayList, HashMap 等等。但是 Collections 类中提供了可以获得不可修改版本(unmodifiable views)的方法:
  • Collections.unmodifiableList
  • Collections.unmodifiableSet
  • Collections.unmodifiableMap

可以将这些不可修改版本当做是对 list / set / map 做了一下包装。如果一个使用者索引的是包装之后的对象,那么 add, remove, put 这些修改就会触发 Unsupported­Operation­Exception 异常。
当我们要向程序另一部分传入可变对象前,可以先用上述方法将其包装。要注意的是,这仅仅是一层包装,如果不小心让别人或自己使用了底层可变对象的索引,这些看起来不可变对象还是会发生变化!

  • Collections 也提供了获取不可变空聚合类型对象的方法,例如 Collections.emptyList

总结

本文引入了一个重要的设计原则:尽量使用不可变对象和不可变引用

  • Safe from bugs. 不可变对象不会因为别名的使用导致 bug,而不可变引用永远指向同一个对象,也会减少 bug 的发生
  • Easy to understand. 因为不可变对象和引用总是意味着不变的东西,所以它们对于读者来说会更易懂——不用一边读代码一边考虑这个时候对象或索引发生了哪些改动
  • Ready for change. 如果一个对象或者引用不会在运行时发生改变,那么在修改程序时,与这些对象或引用有关的代码就不需要重新设计了

标签:return,迭代,原则,int,list,类型,设计,immutable,String
From: https://www.cnblogs.com/Phantasia/p/18196187

相关文章

  • 主流原型设计工具
    原型设计是软件开发过程中的一个重要环节,它帮助开发者和设计师将概念可视化,并通过交互式原型进行测试和反馈。以下是一些主流的原型设计开发工具及其基本使用方法:AxureRP使用方法:AxureRP允许用户创建高保真的交互式原型。用户可以通过拖放界面元素来构建页面,设置交互动作,......
  • openGauss Database和Schema设计
    Database和Schema设计openGauss中可以使用Database和Schema实现业务的隔离,区别在于Database的隔离更加彻底,各个Database之间共享资源极少,可实现连接隔离、权限隔离等,Database之间无法直接互访。Schema隔离的方式共用资源较多,可以通过grant与revoke语法便捷地控制不同用户对各Sche......
  • 原型设计工具
    这篇博客介绍了四种原型设计工具:墨刀、Axure、Figma、AdobeXD。墨刀(Modao):地址:https://modao.cc/墨刀是一款专注于移动端原型设计的在线工具。它的直观界面和丰富的内置组件使得快速原型制作成为可能。墨刀的云端保存和分享功能极大地便利了团队协作和客户演示。尽管它在交......
  • 原型设计工具
    以下介绍几种主流原型设计工具:Axure、墨刀、mockplus、sketch,其中由于选用墨刀进行我们小组项目初步设计原型的工具,因此主要介绍墨刀这个工具,并且通过我们小组的正在进行的项目来举例介绍和展示设计的结果。墨刀:主要特征:便捷性:该原型设计工具提供免费在线版服务,方便用户的使用......
  • 软件设计师基础学习 十三
    十三、结构化开发方法13.1*系统分析与概述1,认识、理解当前的环境,获得当前系统的“物理模型”2.从当前系统的“物理模型”抽象出当前系统的“逻辑模型”3.对当前系统的“逻辑模型”进行分析和优化,建立目标系统的“逻辑模型”4.对目标系统的逻辑模型具体化(物理化),建立目标系......
  • 软件设计师基础学习 十四
    十四、面向对象技术14.1面向对象开发概念:对象:由数据及其操作所构成的封装体,是系统中用来描述客观事务的一个实体,是构成系统的一个基本单位。一个对象通常可以由对象名、属性和方法3个部分组成类:现实世界中实体的形式化描述,类将该实体的属性(数据)和操作(函数)封装在一起......
  • 对于多种原型设计工具的分析
    一、这次是介绍一些主流原型设计工具,并对比介绍以下几种不同的原型设计工具的特点和使用方法。二、1.墨刀①优点1)新建页面简洁,并且支持Axure导入,在新建的时候很多的选项可供选择,分类也是繁多,可以满足不同用户不同的需求2)有一个庞大的素材广场,分类细致,方便设计更丰富多彩......
  • 原型设计工具介绍
    在原型设计领域,除了Axure和墨刀之外,还有其他一些主流的原型设计工具,如即时设计、AdobeXD、Figma等。下面将对这几种工具进行详细介绍和比较。Axure:Axure是一款功能强大的原型设计工具,主要特点包括:丰富的交互元素库:Axure提供了大量的交互元素和组件,用户可以快速创建复杂的交......
  • 主流原型设计工具介绍
    一、原型设计介绍产品原型可以概括的说是整个产品面市之前的一个框架设计。以网站注册作为例子,整个前期的交互设计流程图之后,就是原形开发的设计阶段,简单的来说是将页面的模块、元素、人机交互的形式,利用线框描述的方法,将产品脱离皮肤状态下更加具体跟生动的进行表达。原型设......
  • 主流原型设计工具介绍Axure RP,Sketch,墨刀
    AxureRP工具特点:AxureRP是一款功能强大的原型设计工具,它可以帮助用户创建交互式的网站和应用程序原型。它提供了多种预设组件和模板,用户可以根据自己的需求进行修改。此外,AxureRP还支持多人协作,方便团队内部沟通和合作。使用方法:(1)在AxureRP中创建新项目。(2)选择所需的组......