wenmo8 发布的文章

题目:将一个给定字符串 s 根据给定的行数 numRows ,以从上往下、从左到右进行 Z 字形排列。

这一题作为中等难度,下面和大家分享几种不同的解法。

01
、二维矩阵模拟法

所谓二维矩阵模拟法就是首先构建一个二维矩阵,然后按照题目要求把字符串从上到下,从左到右,把字符一个一个排列到二维矩阵中,然后按行遍历二维矩阵把字符拼接起来即可。

根据上面整体思路,我们可以分为以下几个步骤:

(1)特殊情况处理,对于行数入参为1行,或者字符串长度小于行数入参,可以直接返回字符串无需处理;

(2)构建行数为行数入参,列数为字符串长度的二维矩阵,并把字符串所有字符填充至二维矩阵中;

安装题目要求应该是Z字形,也可以理解成倒N字,其实我们可以稍微变通一下,我们组成一个W形效果也是一样的

经过小小的变形最终效果是一样的,虽然二维矩阵空间变大了,但是我们处理难度会大大降低。因为对于W形列索引只需要以1为步长向前移动即可,而无需像Z字形需要复杂的判断。而行索引两者处理方式相同,从第1行开始向下移动时候步长为1,当到最后1行后步长改为-1,变为向上移动。

这就是为什么二维矩阵的列数选择为字符串长度的原因,可以大大降低处理难度。

(3)按行遍历二维矩阵,并取非空字符,拼接出结果。

具体代码如下:

//二维矩阵模拟法
public static string Matrix(string s, int numRows)
{
    //行数为 1 或者字符串长度小于等于行数,直接返回原字符串
    if (numRows == 1 || s.Length <= numRows)
    {
        return s;
    }
    //构建二维矩阵,用于存储 Z 字形排列的字符
    var matrix = new char[numRows, s.Length];
    //当前行索引
    var rowIndex = 0;
    //行移动步长,向下移动步长为 1 ,向上移动步长为 -1
    var rowStep = 1;
    //遍历字符串
    for (var i = 0; i < s.Length; i++)
    {
        //将当前字符放入二维矩阵中对应的位置
        matrix[rowIndex, i] = s[i];
        if (rowIndex == 0)
        {
            //如果当前行是第一行,则改变行为 1
            //代表字符移动方向为向下
            rowStep = 1;
        }
        else if (rowIndex == numRows - 1)
        {
            //如果当前行是最后一行,则改变行为 -1
            //代表字符移动方向为向上
            rowStep = -1;
        }
        // 根据行步长更新当前行的索引
        rowIndex += rowStep;
    }
    //用于存储最终结果的字符串
    var result = new StringBuilder();
    //遍历二维矩阵的行
    for (var r = 0; r < numRows; r++)
    {
        //遍历二维矩阵的列
        for (var c = 0; c < s.Length; c++)
        {
            //不为空的字符添加到结果字符串中
            if (matrix[r, c] != 0)
            {
                result.Append(matrix[r, c]);
            }
        }
    }
    // 返回最终的 Z 字形变换后的字符串
    return result.ToString();
}

02
、行模拟法(压缩矩阵)

可以发现二维矩阵模拟法还是比较简单的,也吻合我们直观的思维习惯,但是也有一个很大的缺陷,无论是Z字形还是W字形就是浪费很多空间。

因此我们可以对二维矩阵模拟法进行改进,把其行进行压缩,因为最后我们需要以行来展示最终结果,因此我们只需要每一行构建一个字符串,相关行字符串只需要拼接至当前行字符串结尾即可,这样可以最大限度节省空间,需要多少用多少。

该方法需要解决一个核心问题——行索引计算。

再我们需要动态计算一个值时,同时需要找出其规律,要不是公式规律,要不就是周期规律,而本题是在重复Z字形,显然更符合周期规律。而如果以第一行第一个字符为起点,则终点为下个第一行第一个字符之前的一个字符。也就是第一行只有起点一个点,而最后一行只有拐点一个点。因此可以得出周期公式为:[period = numRows * 2 - 2]。

同时行数是入参是已知的,因此我们就可以通过当前字符在当前周期的哪个位置[i % period],再与拐点位置比较确定行索引的前进方向即可。

下面我们一起看看具体代码;

//行模拟法(压缩矩阵)
public static string Row(string s, int numRows)
{
    //行数为 1 或者字符串长度小于等于行数,直接返回原字符串
    if (numRows == 1 || s.Length <= numRows)
    {
        return s;
    }
    //构建字符串数组,每一个StringBuilder代表一行所有字符
    var rows = new StringBuilder[numRows];
    for (int i = 0; i < numRows; ++i)
    {
        rows[i] = new StringBuilder();
    }
    //当前行索引
    var rowIndex = 0;
    // Z 字形变换周期
    var period = numRows * 2 - 2;
    //遍历字符串
    for (int i = 0; i < s.Length; ++i)
    {
        //将当前字符添加到相对应行的末尾
        rows[rowIndex].Append(s[i]);
        //计算当前字符在周期内的那个位置
        //以最后一行的元素为分界线
        //一个周期前半部分为向下移动
        //后半部分为向上移动
        if (i % period < numRows - 1)
        {
            //向下移动
            ++rowIndex;
        }
        else
        {
            //右上移动
            --rowIndex;
        }
    }
    //把字符串数组拼接为最终结果
    var result = new StringBuilder();
    foreach (var row in rows)
    {
        result.Append(row);
    }
    return result.ToString();
}

03
、行模拟法(代码精简)

对于有代码洁癖的我,对上一个解法做了写代码精简,主要精简了三块代码。

(1)字符串数组构建;

这里通过使用Array.ConvertAll方法实现字符串数组的声明与初始化合二为一。

(2)行索引计算方式;

这里利用周期内关于拐点对称性,直接计算出行索引。如果当前字符所在周期索引为periodIndex,则其对称索引为period – periodIndex,而行号则为两者中较小值。

(3)字符串数组拼接字符串;

而拼接方式我们可以直接使用string.Join的重载方法直接拼接。

具体代码如下:

//行模拟法(代码精简)
public static string RowCompact(string s, int numRows)
{
    //行数为 1 或者字符串长度小于等于行数,直接返回原字符串
    if (numRows == 1 || s.Length <= numRows)
    {
        return s;
    }
    //构建字符串数组,每一个StringBuilder代表一行所有字符
    var rows = Array.ConvertAll(new StringBuilder[numRows], _ => new StringBuilder());
    // Z 字形变换周期
    var period = 2 * numRows - 2;
    for (var i = 0; i < s.Length; i++)
    {
        //计算当前字符在周期内的那个位置
        var periodIndex = i % period;
        //获取当前行索引,利用周期内对称性,取最小值确保rowIndex不超过周期的中点
        var rowIndex = Math.Min(periodIndex, period - periodIndex);
        rows[rowIndex].Append(s[i]);
    }
    return string.Join<StringBuilder>("", rows);
}

04
、伪直接构建

前面的方法的思路都是类似的,是先构建行数组,然后把每行字符串拼接好后,再拼接成最终结果。那我们是否可以不去构建行数组,而是直接构建最终字符串呢?

通过前面的解法我们得到Z字形变换周期,这就意味着我们可以根据周期有一次只处理一行字符的条件。而其中重点就是首行和尾行都只有一个字符,而中间的行最多有两个字符。

这样我们就可以循环行数,一行一行构建结果字符串了,具体实现代码如下:

//伪直接构建
public static string Build(string s, int numRows)
{
    //行数为 1 或者字符串长度小于等于行数,直接返回原字符串
    if (numRows == 1 || s.Length <= numRows)
    {
        return s;
    }
    //定义结果动态字符串
    var result = new StringBuilder();
    // Z 字形变换周期
    var period = numRows * 2 - 2;
    //遍历行
    for (var i = 0; i < numRows; ++i)
    {
        //遍历每个周期的起始位置,从 0 开始,步长为 period
        for (var j = 0; j + i < s.Length; j += period)
        {
            //当前周期的第一个字符,添加至结果中
            result.Append(s[j + i]);
            //根据 Z 字形特性,在一个周期内
            //除了第一行和最后一行只有一个字符
            //其他行则至少有一个字符,最多有两个字符
            //因此下面除了第一行和最后一行外,处理当前周期第二个字符
            if (0 < i && i < numRows - 1 && j + period - i < s.Length)
            {
                result.Append(s[j + period - i]);
            }
        }
    }
    return result.ToString();
}

我之所以称此法为伪直接构建,因为我认为还不够直接,我认为的直接构建是:遍历字符串后直接构建出结果。

05
、真直接构建

通过前面的方法,关于Z 字形变换周期计算,关于行的计算,都已经有相应的办法,因此理论上我们是可以通过直接遍历字符串而直接拼接出结果字符串。我们可以梳理一下大致思路。

(1)首先我们构建一个字符数组,拥有存放所有字符;

(2)在这个字符数组中,我们需要动态计算出每一行字符一共站了多少字符,只有这样才能确定下一行第一个字符的起始位置,也就是相当于我们需要动态把字符数组分成n段,每一段代表一行字符;

(3)需要单独处理最后一个周期,因为最后一个周期字符是不确定的,而且它会影响每一行字符的总数,因此需要分类处理好,才能保证第(2)点的计算正确;

因此如果我们需要计算当前行之前行的所有字符总数,则可以分为两部分即完整周期+最后一个周期。

以上图为例,行数为5,最后一个周期字符可能是A-H,即1-8个任意个数,我们需要找到其中规律来确定最后一个周期中,每一行占了多少个字符。

如果以红色横线表示当前处理行,红色竖虚线为周期对称线,则可以把最后一个周期中最后一个字符分布在1、2、3、4四个区域中总结为以三种情况。

(1)当最后字符在1区域,则当前行之前所有行的最后一个周期字符总数为最后字符的索引;

(2)当最后字符在3、4区域,则当前行之前所有行的最后一个周期字符总数为当前行号减1;

(3)当最后字符在2区域,则当前行之前所有行的最后一个周期字符总数为最后字符的索引减去3、4区域的总字符数;

如此我们就可以一次直接构建出结果字符串,当然其中还有一些细节需要处理,比如中间行都有两个字符,而第二个字符需要格外注意,下面我们直接看看整体实现代码:

//真直接构建
public static string Build2(string s, int numRows)
{
    //行数为 1 或者字符串长度小于等于行数,直接返回原字符串
    if (numRows == 1 || s.Length <= numRows)
    {
        return s;
    }
    //定义结果字符数组
    var result = new char[s.Length];
    // Z 字形变换周期
    var period = 2 * numRows - 2;
    //总的周期数
    var totalPeriod = (s.Length + period - 1) / period;
    //最后一个字符的周期索引
    var lastPeriodIndex = s.Length % period;
    lastPeriodIndex = lastPeriodIndex == 0 ? (period - 1) : (lastPeriodIndex - 1);
    //遍历字符串
    for (var i = 0; i < s.Length; i++)
    {
        //当前字符串周期索引
        var periodIndex = i % period;
        //当前行索引
        var rowIndex = Math.Min(periodIndex, period - periodIndex);
        //当前字符索引,以在第几个周期为基础
        var index = (i / period);
        //处理非第一行情况
        if (rowIndex > 0)
        {
            //当前行的起始索引为此行之前所有行的所有字符总和
            //第一行总字符数为总周期数
            //第二行总字符数为分为两部分之后,
            //第一部分是(totalPeriod - 1)个周期,此部分每个周期有2个元素,
            //第二部分是最后一个周期中此行的字符个数,需要动态计算
            index += totalPeriod + (totalPeriod - 1) * 2 * (rowIndex - 1);
            //除首尾行外中间行最多有两个字符
            if (rowIndex != numRows - 1)
            {
                //第二个字符索引起始需要在第几个周期为基础上在加一个第几个周期
                index += (i / period);
            }
            //判断最后一个字符周期索引所在位置
            //动态计算此行之前最后一个周期中包含几个字符
            if (lastPeriodIndex < rowIndex)
            {
                //小于当前行数,则包含最后一个周期的所有字符
                index += lastPeriodIndex;
            }
            else if (lastPeriodIndex < period - rowIndex)
            {
                //小于当前字符对称点,则包含当前行数减1个字符
                index += rowIndex - 1;
            }
            else
            {
                //否则包含最后一个周期所有字符减去此行下面所有行最后一个周期的所有字符
                index += lastPeriodIndex - ((numRows - 1 - rowIndex) * 2 + 1);
            }
        }
        if (periodIndex <= numRows - 1)
        {
            //处理所有行的第一个字符
            result[index] = s[i];
        }
        else
        {
            //处理中间行第二个字符
            result[index + 1] = s[i];
        }
    }
    return new string(result);
}


:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。
https://gitee.com/hugogoos/Planner

介绍

IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理,由Spring容器管理bean的整个生命周期。通俗来说就是
IoC是设计思想,DI是实现方式。

通过反射实现对其他对象的控制,包括初始化、创建、销毁等,解放手动创建对象的过程,同时降低类之间的耦合度。

在 Spring 中,
IoC container
是 Spring 用来实现 IoC 的载体,
IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象,根据BeanName或者Type获取对象

为何是反转,哪些方面反转了?
有反转就有正转,传统应用程序是由我们自己在对象中主动控制去直接获取依赖对象,也就是正转;而反转则是由容器来帮忙创建及注入依赖对象;
控制
:指的是对象创建(实例化、管理)的权力
反转
:控制权交给外部环境(Spring 框架、IoC 容器)

IOC的好处

ioc的思想最核心的地方在于,资源不由使用资源者管理,而由不使用资源的第三方管理,这可以带来很多好处。

  1. 资源集中管理,实现资源的可配置和易管理。
  2. 降低类之间的耦合度。

比如在实际项目中一个 Service 类可能依赖了很多其他的类,假如我们需要实例化这个 Service,可能要每次都要搞清这个 Service 所有底层类的构造函数,这就变得复杂了。而如果使用 IoC 的话,只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。

IOC配置的四种方式


Spring - 概述
一文中已经给出了三种配置方式,这里再总结下;总体上目前的主流方式是
注解 + Java 配置

xml 配置

顾名思义,就是将bean的信息配置.xml文件里,通过Spring加载文件来创建bean。这种方式出现很多早前的SSM项目中,将第三方类库或者一些配置工具类都以这种方式进行配置,主要原因是由于第三方类不支持Spring注解。

  • 优点
    : 可以使用于任何场景,结构清晰,通俗易懂
  • 缺点
    : 配置繁琐,不易维护,枯燥无味,扩展性差

举例

  1. 配置xx.xml文件
  2. 声明命名空间和配置bean
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
 http://www.springframework.org/schema/beans/spring-beans.xsd">
    <!-- services -->
    <bean id="userService" class="com.seven.springframework.service.UserServiceImpl">
        <property name="userDao" ref="userDao"/>
        <!-- additional collaborators and configuration for this bean go here -->
    </bean>
    <!-- more bean definitions for services go here -->
</beans>

Java 配置

将类的创建交给我们配置的JavcConfig类来完成,Spring只负责维护和管理,采用纯Java创建方式。其本质上就是把在XML上的配置声明转移到Java配置类中

  • 优点
    :适用于任何场景,配置方便,因为是纯Java代码,扩展性高,十分灵活
  • 缺点
    :由于是采用Java类的方式,声明不明显,如果大量配置,可读性比较差

举例

  1. 创建一个配置类, 添加@Configuration注解声明为配置类
  2. 创建方法,方法上加上@bean,该方法用于创建实例并返回,该实例创建后会交给spring管理,方法名建议与实例名相同(首字母小写)。注:实例类不需要加任何注解
@Configuration
public class BeansConfig {

    @Bean("userDao")
    public UserDaoImpl userDao() {
        return new UserDaoImpl();
    }

    @Bean("userService")
    public UserServiceImpl userService() {
        UserServiceImpl userService = new UserServiceImpl();
        userService.setUserDao(userDao());
        return userService;
    }
}

注解配置

通过在类上加注解的方式,来声明一个类交给Spring管理,Spring会自动扫描带有@Component,@Controller,@Service,@Repository这四个注解的类,然后帮我们创建并管理,前提是需要先配置Spring的注解扫描器。

  • 优点
    :开发便捷,通俗易懂,方便维护。
  • 缺点
    :具有局限性,对于一些第三方资源,无法添加注解。只能采用XML或JavaConfig的方式配置

举例

  1. 对类添加@Component相关的注解,比如@Controller,@Service,@Repository
  2. 设置ComponentScan的basePackage, 比如在xml文件里设置
    <context:component-scan base-package='com.seven.springframework'>
    , 或者在配置类中设置
    @ComponentScan("com.seven.springframework")
    注解,或者 直接在APP类中
    new AnnotationConfigApplicationContext("com.seven.springframework")
    指定扫描的basePackage.
@Service
public class UserServiceImpl {

    @Autowired
    private UserDaoImpl userDao;

    public List<User> findUserList() {
        return userDao.findUserList();
    }

}

依赖注入DI的方式

其原理是将对象的依赖关系由外部容器来管理和注入。这样,对象只需要关注自身的核心功能,而不需要关心如何获取依赖对象。它的目的是解耦组件之间的依赖关系,提高代码的灵活性、可维护性和可测试性。

在Spring创建对象的过程中,把对象依赖的属性注入到对象中。

依赖注入主要有三种方式:构造器注入,Setter方式注入(属性注入)、基于字段的依赖注入

其中基于字段的依赖注入被广泛使用,但是 idea 或者其他静态代码分析工具会给出提示信息,不推荐使用。

Setter方式注入

在基于 setter 的依赖注入中,setter 方法被标注为 @Autowired。一旦使用无参数构造函数或无参数静态工厂方法实例化 Bean,为了注入 Bean 的依赖项,Spring 容器将调用这些 setter 方法。

  • 在XML配置方式中
    ,property都是setter方式注入,比如下面的xml:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
 http://www.springframework.org/schema/beans/spring-beans.xsd">
    <!-- services -->
    <bean id="userService" class="com.seven.springframework.service.UserServiceImpl">
        <property name="userDao" ref="userDao"/>
        <!-- additional collaborators and configuration for this bean go here -->
    </bean>
    <!-- more bean definitions for services go here -->
</beans>

本质上包含两步:

  1. 第一步,需要new UserServiceImpl()创建对象, 所以需要默认构造函数
  2. 第二步,调用setUserDao()函数注入userDao的值, 所以需要setUserDao()函数
  • 在注解和Java配置下
@Component
public class UserServiceImpl {

    private UserDao userDao;

    @Autowired //这个 @Autowired可以省略
    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }
}

将@Autowired写在被注入的成员变量上,setter或者构造器上,就不用再xml文件中配置了。

基于属性的依赖注入

在基于属性的依赖注入中,以@Autowired(自动注入)注解注入为例,修饰符有三个属性:Constructor,byType,byName。默认按照byType注入。一旦类被实例化,Spring 容器将设置这些字段。

  • constructor
    :通过构造方法进行自动注入,spring会匹配与构造方法参数类型一致的bean进行注入,如果有一个多参数的构造方法,一个只有一个参数的构造方法,在容器中查找到多个匹配多参数构造方法的bean,那么spring会优先将bean注入到多参数的构造方法中。
  • byName
    :被注入bean的id名必须与set方法后半截匹配,并且id名称的第一个单词首字母必须小写,这一点与手动set注入有点不同。
  • byType
    :查找所有的set方法,将符合符合参数类型的bean注入。
@Component
public class FieldBasedInjection {
    @Autowired
    private InjectedBean injectedBean;
}

可能存在的缺点

正如所看到的,这是依赖注入最干净的方法,因为它避免了添加样板代码,并且不需要声明类的构造函数。代码看起来很干净简洁,但是正如代码检查器已经向我们暗示的那样,这种方法有一些缺点:

  1. 不允许声明不可变域
    :基于字段的依赖注入在声明为 final/immutable 的字段上不起作用,因为这些字段必须在类实例化时实例化。声明不可变依赖项的唯一方法是使用基于构造器的依赖注入。
  2. 容易违反单一职责设计原则
    :使用基于字段的依赖注入,高频使用的类随着时间的推移,会在类中逐渐添加越来越多的依赖项,用着很爽,但很容易忽略类中的依赖已经太多了。但是如果使用基于构造函数的依赖注入,随着越来越多的依赖项被添加到类中,构造函数会变得越来越大,一眼就可以察觉到哪里不对劲。
    有一个有超过10个参数的构造函数是一个明显的信号,表明类已经转变一个大而全的功能合集,需要将类分割成更小、更容易维护的块。
    因此,尽管属性注入并不是破坏单一责任原则的直接原因,但它隐藏了信号,使我们很容易忽略这些信号。
  3. 与依赖注入容器紧密耦合

    使用基于字段的依赖注入的主要原因是为了避免 getter 和 setter 的样板代码或为类创建构造函数。最后,这意味着设置这些字段的唯一方法是通过Spring容器实例化类并使用反射注入它们,否则字段将保持 null。
    依赖注入设计模式将类依赖项的创建与类本身分离开来,并将此责任转移到类注入容器,从而允许程序设计解耦,并遵循单一职责和依赖项倒置原则(同样可靠)。因此,通过自动装配(autowiring)字段来实现的类的解耦,最终会因为再次与类注入容器(在本例中是 Spring)耦合而丢失,从而使类在Spring容器之外变得无用。
    这意味着,如果想在应用程序容器之外使用您的类,例如用于单元测试,将被迫使用 Spring 容器来实例化您的类,因为没有其他可能的方法(除了反射)来设置自动装配字段。
  4. 隐藏依赖关系
    :在使用依赖注入时,受影响的类应该使用公共接口清楚地公开这些依赖项,方法是在构造函数中公开所需的依赖项,或者使用方法(setter)公开可选的依赖项。当使用基于字段的依赖注入时,实质上是将这些依赖对外隐藏了。

@Autowired和@Resource以及@Inject等注解注的区别

@Autowired

在Spring 2.5 引入了 @Autowired 注解

@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Autowired {
  boolean required() default true;//默认是true
}

从Autowired注解源码上看,可以使用在下面这些地方:

@Target(ElementType.CONSTRUCTOR) #构造函数
@Target(ElementType.METHOD) #方法
@Target(ElementType.PARAMETER) #方法参数
@Target(ElementType.FIELD) #字段、枚举的常量
@Target(ElementType.ANNOTATION_TYPE) #注解
  • 简单总结
  1. @Autowired是Spring自带的注解,通过AutowiredAnnotationBeanPostProcessor 类实现的依赖注入
  2. @Autowired可以作用在CONSTRUCTOR、METHOD、PARAMETER、FIELD、ANNOTATION_TYPE
  3. @Autowired默认是根据类型(byType )进行自动装配的
  4. 如果有多个类型一样的Bean候选者,需要指定按照名称(byName )进行装配,则需要配合 @Qualifier。
    指定名称后,如果Spring IOC容器中没有对应的组件bean抛出NoSuchBeanDefinitionException。也可以将@Autowired中required配置为false,如果配置为false之后,当没有找到相应bean的时候,系统不会抛异常

在SpringBoot中也可以使用@Bean + @Autowired进行组件注入,将@Autowired加到参数上,其实也可以省略。

@Bean
public Person getPerson(@Autowired Car car){
 return new Person();
}
// @Autowired 其实也可以省略
@Resource

Resource注解源码:

@Target({TYPE, FIELD, METHOD})
@Retention(RUNTIME)
public @interface Resource {
    String name() default "";//指定注入指定名称的组件,name 的作用类似 @Qualifier
    // 其他省略
}

从Resource注解源码上看,可以使用在下面这些地方:

@Target(ElementType.TYPE) #接口、类、枚举、注解
@Target(ElementType.FIELD) #字段、枚举的常量
@Target(ElementType.METHOD) #方法
  • 简单总结
  1. @Resource是JSR250规范的实现,在javax.annotation包下
  2. @Resource可以作用TYPE、FIELD、METHOD上
  3. @Resource是默认根据属性名称进行自动装配的,如果有多个类型一样的Bean候选者,则可以通过name进行指定进行注入
@Inject
  • Inject注解源码
@Target({ METHOD, CONSTRUCTOR, FIELD })
@Retention(RUNTIME)
@Documented
public @interface Inject {}

从Inject注解源码上看,可以使用在下面这些地方:

@Target(ElementType.CONSTRUCTOR) #构造函数
@Target(ElementType.METHOD) #方法
@Target(ElementType.FIELD) #字段、枚举的常量
  • 简单总结
  1. @Inject是JSR330 (Dependency Injection for Java)中的规范,需要导入javax.inject.Inject jar包 ,才能实现注入
  2. @Inject可以作用CONSTRUCTOR、METHOD、FIELD上
  3. @Inject是根据类型进行自动装配的,如果需要按名称进行装配,则需要配合@Named;@Named 的作用类似 @Qualifier!

构造器注入

  • 在XML配置方式中

    <constructor-arg>
    是通过构造函数参数注入,比如下面的xml:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
 http://www.springframework.org/schema/beans/spring-beans.xsd">
    <!-- services -->
    <bean id="userService" class="com.seven.springframework.service.UserServiceImpl">
        <constructor-arg name="userDao" ref="userDao"/>
        <!-- additional collaborators and configuration for this bean go here -->
    </bean>
    <!-- more bean definitions for services go here -->
</beans>
  • 在注解和Java配置方式下

在基于构造函数的依赖注入中,类构造函数被标注为 @Autowired,并包含了许多与要注入的对象相关的参数。

@Component
public class ConstructorBasedInjection {
    
    private final InjectedBean injectedBean;
    
    @Autowired //当然,这个@Autowired可以省略
    public ConstructorBasedInjection(InjectedBean injectedBean) {        
        this.injectedBean = injectedBean;    
    }
}

将@Autowired写在被注入的成员变量上,setter或者构造器上,就不用再xml文件中配置了。
注意
:不能提供无参构造方法,否则Springboot默认会加载无参的构造方法,Bean实例对象会为null。并且构造器的权限需要为public

为什么建议使用构造器注入

一般推荐构造器注入,为什么?Spring文档里的解释如下:

The Spring team generally advocates constructor injection as it enables one to implement application components as immutable objects and to ensure that required dependencies are not null. Furthermore constructor-injected components are always returned to client (calling) code in a fully initialized state.

翻译一下就是:这个构造器注入的方式能够保证注入的组件不可变,并且确保需要的依赖不为空。此外,构造器注入的依赖总是能够在返回客户端(组件)代码的时候保证完全初始化的状态。

  • 依赖不可变
    :其实说的就是final关键字。

  • 依赖不为空
    :(省去了我们对其检查):当要实例化UserServiceImpl的时候,由于自己实现了有参数的构造函数,所以不会调用默认构造函数,那么就需要Spring容器传入所需要的参数,所以就两种情况:1、有该类型的参数->传入,OK 。2:无该类型的参数->报错。

  • 完全初始化的状态
    :这个可以跟上面的依赖不为空结合起来,向构造器传参之前,要确保注入的内容不为空,那么肯定要调用依赖组件的构造方法完成实例化。而在Java类加载实例化的过程中,构造方法是最后一步(之前如果有父类先初始化父类,然后自己的成员变量,最后才是构造方法),所以返回来的都是初始化之后的状态。

如果使用setter注入,缺点显而易见,对于IOC容器以外的环境,除了使用反射来提供它需要的依赖之外,无法复用该实现类。而且将一直是个潜在的隐患,因为你不调用将一直无法发现NPE的存在

// 这里只是模拟一下,正常来说我们只会暴露接口给客户端,不会暴露实现。 
UserServiceImpl userService = newUserServiceImpl(); 
userService.findUserList();// -> NullPointerException, 潜在的隐患

总结
:对于必需的依赖,建议使用基于构造函数的注入,设置它们为不可变的,并防止它们为 null。对于可选的依赖项,建议使用基于 setter 的注入。

借助lombok简化

然而,手动编写构造函数可能会使代码
显得冗长不美观
。Lombok 是一个非常好的工具,它能够通过注解自动生成构造函数,从而使代码更加简洁和优雅。

Lombok提供的
@AllArgsConstructor
注解可以帮助我们自动生成包含所有字段的构造函数。此外,
@RequiredArgsConstructor
注解可以生成包含所有
final
字段和带有
@NonNull
注解字段的构造函数,这通常是我们在依赖注入中需要的。

@Component
@RequiredArgsConstructor // 自动生成包含final字段的构造函数
public class ConstructorBasedInjection {
    
    private final InjectedBean injectedBean;
    
}

Bean注入的七种方式

  1. 使用xml方式来声明Bean的定义,Spring容器在启动会加载并解析这个xml,把bean装载到IOC容器中

  2. 使用@CompontScan注解来扫描声明了@Controller、@Service、@Repository、@Component注解的类

  3. 使用@Configuration注解声明配置类,并使用@Bean注解实现Bean的定义,这种方式其实是xml配置方式的一种演变,是Spring迈入到无xml 时代的里程碑

  4. 使用@Import注解,导入配置类或者普通的Bean

  5. 使用FactoryBean工厂bean, 动态构建一个Bean实例,Spring Cloud OpenFeign 里面的动态代理实例就是使用FactoryBean来实现的

  6. 实现ImportBeanDefinitionRegistrar接口,可以动态注入Bean实例。这个在Spring Boot里面的启动注解有用到

  7. 实现ImportSelector接口,动态批量注入配置类或者Bean对象,这个在Spring Boot里面的自动装配机制里面有用到

Bean的作用域

  1. singleton
    :单例,Spring中的bean默认都是单例的。
  2. prototype
    :每次请求都会创建一个新的bean实例。
  3. request
    :每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP request内有效。
  4. session
    :每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP session内有效。
  5. global-session
    :全局session作用域。
  6. websocket
    (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。

如何配置 bean 的作用域呢?

xml 方式:

<bean id="..." class="..." scope="singleton"></bean>

注解方式:

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Person personPrototype() {
    return new Person();
}

Bean 是线程安全的吗

Spring 框架中的 Bean 是否线程安全,取决于其作用域和状态。

这里以最常用的两种作用域 prototype 和 singleton 为例介绍。几乎所有场景的 Bean 作用域都是使用默认的 singleton ,重点关注 singleton 作用域即可。

prototype 作用域
下,每次获取都会创建一个新的 bean 实例,不存在资源竞争问题,所以不存在线程安全问题。
singleton 作用域
下,IoC 容器中只有唯一的 bean 实例,可能会存在资源竞争问题(取决于 Bean 是否有状态)。如果这个 bean 是有状态的话,那就存在线程安全问题(有状态 Bean 是指包含可变的成员变量的对象)。

不过,大部分 Bean 实际都是无状态(没有定义可变的成员变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。

对于有状态单例 Bean 的线程安全问题,常见的有两种解决办法:

  1. 在 Bean 中尽量避免定义可变的成员变量。
  2. 在类中定义一个
    ThreadLocal
    成员变量,将需要的可变成员变量保存在
    ThreadLocal
    中(推荐的一种方式)。

IOC的结构设计

Spring Bean的创建是典型的
工厂模式
,这一系列的Bean工厂,也即IOC容器为开发者管理对象间的依赖关系提供了很多便利和基础服务,在Spring中有许多的IOC容器的实现供用户选择和使用,这是IOC容器的基础;在顶层的结构设计主要围绕着BeanFactory和xxxRegistry进行:

BeanFactory

  • BeanFactory: 工厂模式定义了IOC容器的基本功能规范

BeanFactory作为最顶层的一个接口类,它定义了IOC容器的基本功能规范
,BeanFactory 有三个子类:ListableBeanFactory、HierarchicalBeanFactory 和AutowireCapableBeanFactory。先看下BeanFactory接口:

public interface BeanFactory {    
      
    //用于取消引用实例并将其与FactoryBean创建的bean区分开来。例如,如果命名的bean是FactoryBean,则获取将返回Factory,而不是Factory返回的实例。
    String FACTORY_BEAN_PREFIX = "&"; 
        
    //根据bean的名字和Class类型等来得到bean实例    
    Object getBean(String name) throws BeansException;    
    Object getBean(String name, Class requiredType) throws BeansException;    
    Object getBean(String name, Object... args) throws BeansException;
    <T> T getBean(Class<T> requiredType) throws BeansException;
    <T> T getBean(Class<T> requiredType, Object... args) throws BeansException;

    //返回指定bean的Provider
    <T> ObjectProvider<T> getBeanProvider(Class<T> requiredType);
    <T> ObjectProvider<T> getBeanProvider(ResolvableType requiredType);

    //检查工厂中是否包含给定name的bean,或者外部注册的bean
    boolean containsBean(String name);

    //检查所给定name的bean是否为单例/原型
    boolean isSingleton(String name) throws NoSuchBeanDefinitionException;
    boolean isPrototype(String name) throws NoSuchBeanDefinitionException;

    //判断所给name的类型与type是否匹配
    boolean isTypeMatch(String name, ResolvableType typeToMatch) throws NoSuchBeanDefinitionException;
    boolean isTypeMatch(String name, Class<?> typeToMatch) throws NoSuchBeanDefinitionException;

    //获取给定name的bean的类型
    @Nullable
    Class<?> getType(String name) throws NoSuchBeanDefinitionException;

    //返回给定name的bean的别名
    String[] getAliases(String name);
     
}

BeanFactory的其他接口主要是为了区分在 Spring 内部在操作过程中对象的传递和转化过程中,对对象的数据访问所做的限制。

  • ListableBeanFactory:该接口定义了访问容器中 Bean 基本信息的若干方法,如查看Bean 的个数、获取某一类型 Bean 的配置名、查看容器中是否包括某一 Bean 等方法;
  • HierarchicalBeanFactory:父子级联 IoC 容器的接口,子容器可以通过接口方法访问父容器; 通过 HierarchicalBeanFactory 接口, Spring 的 IoC 容器可以建立父子层级关联的容器体系,子容器可以访问父容器中的 Bean,但父容器不能访问子容器的 Bean。Spring 使用父子容器实现了很多功能,比如在 Spring MVC 中,展现层 Bean 位于一个子容器中,而业务层和持久层的 Bean 位于父容器中。这样,展现层 Bean 就可以引用业务层和持久层的 Bean,而业务层和持久层的 Bean 则看不到展现层的 Bean。
  • ConfigurableBeanFactory:是一个重要的接口,增强了 IoC 容器的可定制性,它定义了设置类装载器、属性编辑器、容器初始化后置处理器等方法;
  • ConfigurableListableBeanFactory: ListableBeanFactory 和 ConfigurableBeanFactory的融合;AutowireCapableBeanFactory:定义了将容器中的 Bean 按某种规则(如按名字匹配、按类型匹配等)进行自动装配的方法;

BeanRegistry

  • BeanRegistry: 向IOC容器手工注册 BeanDefinition 对象的方法

Spring 配置文件中每一个节点元素在 Spring 容器里都通过一个 BeanDefinition 对象表示,它描述了 Bean 的配置信息。而 BeanDefinitionRegistry 接口提供了向容器手工注册 BeanDefinition 对象的方法。

BeanDefinition

各种Bean对象及其相互的关系

  • BeanDefinition 定义了各种Bean对象及其相互的关系

  • BeanDefinitionReader 这是BeanDefinition的解析器

  • BeanDefinitionHolder 这是BeanDefination的包装类,用来存储BeanDefinition,name以及aliases等。

BeanDefinition

SpringIOC容器管理了定义的各种Bean对象及其相互的关系,Bean对象在Spring实现中是以BeanDefinition来描述的,其继承体系如下

BeanDefinitionReader

Bean 的解析过程非常复杂,功能被分的很细,因为这里需要被扩展的地方很多,必须保证有足够的灵活性,以应对可能的变化。Bean 的解析主要就是对 Spring 配置文件的解析。这个解析过程主要通过下图中的类完成:

BeanDefinitionHolder

BeanDefinitionHolder 是BeanDefination的包装类,用来存储BeanDefinition,name以及aliases等

ApplicationContext

IoC容器的接口类是ApplicationContext,很显然它必然继承BeanFactory对Bean规范(最基本的ioc容器的实现)进行定义。而ApplicationContext表示的是应用的上下文,除了对Bean的管理外,还至少应该包含了

  • 访问资源: 对不同方式的Bean配置(即资源)进行加载。(实现ResourcePatternResolver接口)

  • 国际化: 支持信息源,可以实现国际化。(实现MessageSource接口)

  • 应用事件: 支持应用事件。(实现ApplicationEventPublisher接口)

接口设计

ApplicationContext整体结构:

  • HierarchicalBeanFactory 和 ListableBeanFactory: ApplicationContext 继承了 HierarchicalBeanFactory 和 ListableBeanFactory 接口,在此基础上,还通过多个其他的接口扩展了BeanFactory的功能
  • ApplicationEventPublisher:让容器拥有发布应用上下文事件的功能,包括容器启动事件、关闭事件等。实现了 ApplicationListener 事件监听接口的 Bean 可以接收到容器事件 , 并对事件进行响应处理 。 在 ApplicationContext 抽象实现类AbstractApplicationContext 中,我们可以发现存在一个 ApplicationEventMulticaster,它负责保存所有监听器,以便在容器产生上下文事件时通知这些事件监听者。
  • MessageSource:为应用提供 i18n 国际化消息访问的功能;
  • ResourcePatternResolver : 所 有 ApplicationContext 实现类都实现了类似于PathMatchingResourcePatternResolver 的功能,可以通过带前缀的 Ant 风格的资源文件路径装载 Spring 的配置文件。
  • LifeCycle:该接口是 Spring 2.0 加入的,该接口提供了 start()和 stop()两个方法,主要用于控制异步处理过程。在具体使用时,该接口同时被 ApplicationContext 实现及具体 Bean 实现, ApplicationContext 会将 start/stop 的信息传递给容器中所有实现了该接口的 Bean,以达到管理和控制 JMX、任务调度等目的

接口的实现

ApplicationContext接口的实现,关键的点在于,不同Bean的配置方式(比如xml,groovy,annotation等)有着不同的资源加载方式,这便衍生除了众多ApplicationContext的实现类。

  1. 第一,从类结构设计上看, 围绕着是否需要Refresh容器衍生出两个抽象类:


    1. GenericApplicationContext: 是初始化的时候就创建容器,往后的每次refresh都不会更改

    2. AbstractRefreshableApplicationContext: AbstractRefreshableApplicationContext及子类的每次refresh都是先清除已有(如果不存在就创建)的容器,然后再重新创建;AbstractRefreshableApplicationContext及子类无法做到GenericApplicationContext混合搭配从不同源头获取bean的定义信息

  2. 第二, 从加载的源来看(比如xml,groovy,annotation等), 衍生出众多类型的ApplicationContext, 典型比如:


    1. FileSystemXmlApplicationContext: 从文件系统下的一个或多个xml配置文件中加载上下文定义,也就是说系统盘符中加载xml配置文件。

    2. ClassPathXmlApplicationContext: 从类路径下的一个或多个xml配置文件中加载上下文定义,适用于xml配置的方式。

    3. AnnotationConfigApplicationContext: 从一个或多个基于java的配置类中加载上下文定义,适用于java注解的方式。

    4. ConfigurableApplicationContext: 扩展于 ApplicationContext,它新增加了两个主要的方法: refresh()和 close(),让 ApplicationContext 具有启动、刷新和关闭应用上下文的能力。在应用上下文关闭的情况下调用 refresh()即可启动应用上下文,在已经启动的状态下,调用 refresh()则清除缓存并重新装载配置信息,而调用close()则可关闭应用上下文。这些接口方法为容器的控制管理带来了便利,但作为开发者,我们并不需要过多关心这些方法。

  3. 第三, 更进一步理解:


    1. 设计者在设计时AnnotationConfigApplicationContext为什么是继承GenericApplicationContext? 因为基于注解的配置,是不太会被运行时修改的,这意味着不需要进行动态Bean配置和刷新容器,所以只需要GenericApplicationContext。

    2. 而基于XML这种配置文件,这种文件是容易修改的,需要动态性刷新Bean的支持,所以XML相关的配置必然继承AbstractRefreshableApplicationContext; 且存在多种xml的加载方式(位置不同的设计),所以必然会设计出AbstractXmlApplicationContext, 其中包含对XML配置解析成BeanDefination的过程。

面试题专栏

Java面试题专栏
已上线,欢迎访问。

  • 如果你不知道简历怎么写,简历项目不知道怎么包装;
  • 如果简历中有些内容你不知道该不该写上去;
  • 如果有些综合性问题你不知道怎么答;

那么可以私信我,我会尽我所能帮助你。


Reviewbot
是七牛云开源的一个项目,旨在提供一个自托管的代码审查服务, 方便做 code review/静态检查, 以及自定义工程规范的落地。


自从上了 Reviewbot 之后,我发现有些 lint 错误,还是很容易出现的。比如

dao/files_dao.go:119:2: `if state.Valid() || !start.IsZero() || !end.IsZero()` has complex nested blocks (complexity: 6) (nestif)
cognitive complexity 33 of func (*GitlabProvider).Report is high (> 30) (gocognit)

这两个检查,都是圈复杂度相关。

圈复杂度(Cyclomatic complexity)是由 Thomas McCabe 提出的一种度量代码复杂性的指标,用于计算程序中线性独立路径的数量。它通过统计程序控制流中的判定节点(如 if、for、while、switch、&&、|| 等)来计算。圈复杂度越高,表示代码路径越多,测试和维护的难度也就越大。

圈复杂度高的代码,往往意味着代码的可读性和可维护性差,非常容易出 bug。

为什么这么说呢?其实就跟人脑处理信息一样,一件事情弯弯曲曲十八绕,当然容易让人晕。

所以从工程实践角度,我们希望代码的圈复杂度不能太高,毕竟绝大部分代码不是一次性的,是需要人来维护的。

那该怎么做呢?

这里我首先推荐一个简单有效的方法:
Early return

Early return - 逻辑展平,减少嵌套

Early return, 也就是提前返回,是我个人认为最简单,日常很多新手同学容易忽视的方法。

举个例子:

func validate(data *Data) error {
    if data != nil {
        if data.Field != "" {
            if checkField(data.Field) {
                return nil
            }
        }
    }
    return errors.New("invalid data")
}

这段代码的逻辑应该挺简单的,但嵌套层级有点多,如果以后再复杂一点,就容易出错。

这种情况就可以使用 early return 模式改写,把这个嵌套展平:

func validate(data *Data) error {
    if data == nil {
        return errors.New("data is nil")
    }
    if data.Field == "" {
        return errors.New("field is empty")
    }
    if !checkField(data.Field) {
        return errors.New("field validation failed")
    }
    return nil
}

是不是清晰很多,看着舒服多了?

记住这里的诀窍:
如果你觉得顺向思维写出的代码有点绕,且嵌套过多的话,就可以考虑使用 early return 来反向展平。

当然,严格意义上讲,early return 只能算是一种小技巧。要想写出高质量的代码,最重要的还是理解 分层、组合、单一职责、高内聚低耦合、SOLID 原则等 这些核心设计理念 和 设计模式了。

Functional Options 模式 - 参数解耦

来看一个场景: 方法参数很多,怎么办?

比如这种:

func (s *Service) DoSomething(ctx context.Context, a, b, c, d int) error {
    // ...
}

有一堆参数,而且还是同类型的。如果在调用时,一不小心写错了参数位置,就很麻烦,因为编译器并不能检查出来。

当然,即使不是同类型的,参数多了可能看着也不舒服。

怎么解决?

这种情况,可以选择将参数封装成一个结构体,这样在使用时就会方便很多。封装成结构体后还有一个好处,就是以后增删参数时(结构体的属性),方法签名不需要修改。避免了以前需要改方法签名时,调用方也需要跟着到处改的麻烦。

不过,在 Go 语言中,还有一种更优雅的解决方案,那就是
Functional Options 模式

不管是
Rob Pike
还是
Dave Cheney
以及 uber 的
go guides
中都有专门的推荐。

这种模式,本质上就是利用了闭包的特性,将参数封装成一个匿名函数,有诸多妙用。

Reviewbot 自身的代码中,就有相关的使用场景(
https://github.com/qiniu/reviewbot/blob/c354fde07c5d8e4a51ddc8d763a2fac53c3e13f6/internal/lint/providergithub.go#L263
),比如:

// GithubProviderOption allows customizing the provider creation.
type GithubProviderOption func(*GithubProvider)
func NewGithubProvider(ctx context.Context, githubClient *github.Client, pullRequestEvent github.PullRequestEvent, options ...GithubProviderOption) (*GithubProvider, error) {
    // ...
    for _, option := range options {
        option(p)
    }
    // ...
    if p.PullRequestChangedFiles == nil {
        // call github api to get changed files
    }
    // ...
}

这里的
options
就是 functional options 模式,可以灵活地传入不同的参数。

当时之所以选择这种写法,一个重要的原因是方便单测书写。

为什么这么说呢?

看上述代码能知道,它需要
调用 github api 去获取 changed files
, 这种实际依赖外部的场景,在单测时就很麻烦。但是,我们用了 functional options 模式之后,就可以通过
p.PullRequestChangedFiles
是否为 nil 这个条件,灵活的绕过这个问题。

Functional Options 模式的优点还有很多,总结来讲(from dave.cheney):

  • Functional options let you write APIs that can grow over time.
  • They enable the default use case to be the simplest.
  • They provide meaningful configuration parameters.
  • Finally they give you access to the entire power of the language to initialize complex values.

现在大模型相关的代码,能看到很多 functional options 的影子。比如
https://github.com/tmc/langchaingo/blob/238d1c713de3ca983e8f6066af6b9080c9b0e088/llms/ollama/options.go#L25

type Option func(*options)
// WithModel Set the model to use.
func WithModel(model string) Option {
	return func(opts *options) {
		opts.model = model
	}
}
// WithFormat Sets the Ollama output format (currently Ollama only supports "json").
func WithFormat(format string) Option {
    // ...
}
//	If not set, the model will stay loaded for 5 minutes by default
func WithKeepAlive(keepAlive string) Option {
    // ...
}

所以建议大家在日常写代码时,也多有意识的尝试下。

善用 Builder 模式/策略模式/工厂模式,消弭复杂 if-else

Reviewbot 目前已支持两种 provider(github 和 gitlab),以后可能还会支持更多。

而因为不同的 Provider 其鉴权方式还可能不一样,比如:

  • github 目前支持 Github APP 和 Personal Access Token 两种方式
  • gitlab 目前仅支持 Personal Access Token 方式

当然,还有 OAuth2 方式,后面 reviewbot 也也会考虑支持。

那这里就有一个问题,比如在 clone 代码时,该使用哪种方式?代码该怎么写?使用 token 的话,还有个 token 过期/刷新的问题,等等。

如果使用 if-else 模式来实现,代码就会变得很复杂,可读性较差。类似这种:

if provider == "github" {
    // 使用 Github APP 方式
    if githubClient.AppID != "" && githubClient.AppPrivateKey != "" {
        // 使用 Github APP 方式
        // 可能需要调用 github api 获取 token
    } else if githubClient.PersonalAccessToken != "" {
        // 使用 Personal Access Token 方式
        // 可能需要调用 github api 获取 token
    } else {
        return err
    }
} else if provider == "gitlab" {
    // 使用 Personal Access Token 方式
    if gitlabClient.PersonalAccessToken != "" {
        // 使用 Personal Access Token 方式
        // 可能需要调用 gitlab api 获取 token
    } else {
        return errors.New("gitlab personal access token is required")
    }
}

但现在 Reviewbot 的代码中,相关代码仅两行:

func (s *Server) handleSingleRef(ctx context.Context, ref config.Refs, org, repo string, platform config.Platform, installationID int64, num int, provider lint.Provider) error {
    // ...
	gb := s.newGitConfigBuilder(ref.Org, ref.Repo, platform, installationID, provider)
	if err := gb.configureGitAuth(&opt); err != nil {
		return fmt.Errorf("failed to configure git auth: %w", err)
	}
	// ...
}

怎么做到的呢?

其实是使用了 builder 模式,将 git 的配置和创建过程封装成一个 builder,然后根据不同的 provider 选择不同的 builder,从而消弭了复杂的 if-else 逻辑。

当然内部细节还很多,不过核心思想都是将复杂的逻辑封装起来,在主交互逻辑中,只暴露简单的使用接口,这样代码的可读性和可维护性就会大大提高。

最后

到底如何写出高质量的代码呢?这可能是很多有追求的工程师,一直在思考的问题。

在我看来,可能是没有标准答案的。不过呢,知道一些技巧,并能在实战中灵活运用,总归是好的。

你说是吧?

序言:许多人最初接触人工智能都是在ChatGPT火热之际,并且大多停留在应用层面。对于希望了解其技术根源的人来说,往往难以找到方向。因此,我们编写了《人工智能大语言模型起源篇》,旨在帮助读者找到正确的学习路径,了解大型语言模型的大致起源。本文将分为三个部分,介绍当前主流的大型语言模型架构Transformer(变换器)模型的起源及其发展历程。Transformer并非横空出世,而是人工智能领域研究者们在长期探索和实验中逐步发展起来的。

大型语言模型(LLM)早已经征服了当今的人工智能领域——这不是开玩笑。在短短五年多的时间里,大型语言模型——即变换器(Transformers)——几乎彻底改变了自然语言处理领域。而且,它们正在彻底改变计算机视觉和计算生物学等领域。

由于变换器对每个人的研究议程产生了如此大的影响,今天的这篇文章我想为那些刚刚入门的人工智能学习研究者和从业者整理一份简短的渐进式阅读清单。

建议按下面清单的先后顺序来阅读,这些则主要是专注于学术研究论文。当然,市场上还有很多其他有用的资源:

Jay Alammar 的《Illustrated Transformer》
http://jalammar.github.io/illustrated-transformer/;

Lilian Weng 的《一篇更技术性的博客文章》
https://lilianweng.github.io/posts/2020-04-07-the-transformer-family/;

Xavier Amatriain 汇总并绘制的《所有主要变换器的目录和家谱》
https://amatriain.net/blog/transformer-models-an-introduction-and-catalog-2d1e9039f376/;

Andrej Karpathy 为了教育目的提供的《生成语言模型的最简代码实现》
https://github.com/karpathy/nanoGPT;

Sebastian Raschka的《讲座》
https://sebastianraschka.com/blog/2021/dl-course.html#l19-self-attention-and-transformer-networks和《书籍》https://github.com/rasbt/machine-learning-book/tree/main/ch16。

理解主要架构和任务

如果你是第一次接触变换器 / 大型语言模型,那么最好从头开始。

(1)Bahdanau、Cho 和 Bengio 于2014年发表的《通过联合学习对齐和翻译的神经机器翻译》(Neural Machine Translation by Jointly Learning to Align and Translate),
https://arxiv.org/abs/1409.0473

如果你有几分钟的时间,我建议从上述论文开始。它介绍了一种用于递归神经网络(RNN)的注意力机制,以提高长序列建模能力。这使得RNN能够更准确地翻译更长的句子——这也是后来开发原始变换器架构的动机。


图片来源:
https://arxiv.org/abs/1409.0473

(2)Vaswani、Shazeer、Parmar、Uszkoreit、Jones、Gomez、Kaiser 和 Polosukhin 于2017年发表的《Attention Is All You Need》,
https://arxiv.org/abs/1706.03762

上面的论文介绍了原始的变换器架构,包括一个编码器和一个解码器部分,这两个部分后来会作为独立的模块变得非常重要。此外,这篇论文还介绍了一些概念,比如缩放点积注意力机制、多头注意力模块和位置输入编码,这些都成为了现代变换器的基础。


图片来源:
https://arxiv.org/abs/1706.03762

(3)Xiong、Yang、He、K Zheng、S Zheng、Xing、Zhang、Lan、Wang 和 Liu 于2020年发表的《On Layer Normalization in the Transformer Architecture》,
https://arxiv.org/abs/2002.04745

虽然上面这张来自《Attention Is All You Need》(
https://arxiv.org/abs/1706.03762)的原始变换器图是对原始编码器-解码器架构的有用总结,但图中层归一化(LayerNorm)的位置一直是一个备受争议的话题。

举个例子,《Attention Is All You Need》中的变换器图将层归一化放在残差块之间,这与原始变换器论文中附带的官方https://github.com/tensorflow/tensor2tensor/commit/f5c9b17e617ea9179b7d84d36b1e8162cb369f25(更新版)代码实现不一致。图中所示的变体被称为Post-LN变换器,而更新的代码实现默认使用的是Pre-LN变体。

《On Layer Normalization in the Transformer Architecture》
https://arxiv.org/abs/2002.04745这篇论文指出,Pre-LN效果更好,能解决梯度问题,如下所示。许多架构在实践中采用了这一方法,但它可能会导致表示崩塌。

所以,虽然目前关于使用Post-LN还是Pre-LN的讨论仍在继续,但也有一篇新论文提出了利用两者优势的方案:ResiDual:带有双残差连接的变换器(
https://arxiv.org/abs/2304.14802);它是否在实践中有用,仍有待观察。


图片来源:
https://arxiv.org/abs/1706.03762(左和中)以及
https://arxiv.org/abs/2002.04745(右)

(4)Schmidhuber 于1991年发表的《Learning to Control Fast-Weight Memories: An Alternative to Dynamic Recurrent Neural Networks》,
https://www.semanticscholar.org/paper/Learning-to-Control-Fast-Weight-Memories%3A-An-to-Schmidhuber/bc22e87a26d020215afe91c751e5bdaddd8e4922

这篇论文推荐给那些对历史细节以及与现代变换器(Transformers)原理上有相似性的早期方法感兴趣的人。

例如,在1991年,也就是大约在上述原始变换器论文(《Attention Is All You Need》)发布的二十五年半之前,Juergen Schmidhuber 提出了一个递归神经网络的替代方案,称为快速权重编程(Fast Weight Programmers,FWP)。FWP方法涉及一个前馈神经网络,通过梯度下降慢慢学习来编程另一个神经网络的快速权重变化。

这个与现代变换器的类比在这篇博客文章https://people.idsia.ch//~juergen/fast-weight-programmer-1991-transformer.html#sec2中是这样解释的:

在今天的变换器术语中,FROM 和 TO 分别被称为键(key)和值(value)。应用于快速网络的输入被称为查询(query)。本质上,查询通过快速权重矩阵处理,后者是键和值外积的和(忽略归一化和投影)。由于两个网络的所有操作都是可微的,我们通过加法外积或二阶张量积获得了快速权重变化的端到端可微的主动控制。[FWP0-3a] 因此,慢网络可以通过梯度下降学习在序列处理过程中快速修改快网络。这在数学上等价(除了归一化)于后来被称为具有线性自注意力的变换器(或线性变换器)。

正如上面博客摘录所提到的,这种方法现在被称为“线性变换器”或“具有线性化自注意力的变换器”,通过2020年在arXiv上发布的几篇论文《Transformers are RNNs: Fast Autoregressive Transformers with Linear Attention》
https://arxiv.org/abs/2006.16236和《Rethinking
Attention with Performers》
https://arxiv.org/abs/2009.14794进一步阐明了这一点。

2021年,论文《Linear Transformers Are Secretly Fast Weight Programmers》则明确展示了线性化自注意力和1990年代的快速权重编程之间的等价性。”


来源:基于https://people.idsia.ch//~juergen/fast-weight-programmer-1991-transformer.html#sec2的注释图

前言


BigDecimal
是Java编程语言中位于
java.math包
中的一个类,主要用于进行高精度的十进制数计算‌。它提供了对任意精度的十进制数进行精确计算的能力,适用于需要保持精度和执行准确计算的场景‌

BigDecimal使用基于整数的表示方法,通过存储和处理数值的每一位来避免精度丢失。这使得它可以表示极大或极小的数字,并执行准确的计算。与基本的浮点数类型(如float和double)不同,BigDecimal通过调用相应的方法来进行数学运算,而不是使用传统的+、-、*、/等算术运算符‌

BigDecimal广泛应用于金融领域、货币计算、税务计算、精确计算需求以及其他需要保持精度和执行准确计算的场景。由于BigDecimal对象是不可变的,每个操作都会产生一个新的BigDecimal对象作为结果,这在某些情况下可能会影响性能。

1.BigDecimal数值运算

        BigDecimal a = new BigDecimal("10.123");
BigDecimal b
= new BigDecimal("2.123");//加法 BigDecimal addResult =a.add(b);
System.out.println(
"加法结果: " +addResult);//减法 BigDecimal subtractResult =a.subtract(b);
System.out.println(
"减法结果: " +subtractResult);//乘法 BigDecimal multiplyResult =a.multiply(b);
System.out.println(
"乘法结果: " +multiplyResult);//除法,并设置舍入模式为四舍五入 BigDecimal divideResult = a.divide(b, 2, RoundingMode.HALF_UP);
System.out.println(
"除法结果: " + divideResult);

2.bigDecimal数据转换

        //1.int转BigDecimal
        int intValue = 10;
BigDecimal intToBigDecimal
= newBigDecimal(intValue);
System.out.println(
"int转BigDecimal: " +intToBigDecimal);//2.double转BigDecimal double doubleValue = 10.123;
BigDecimal doubleToBigDecimal
= newBigDecimal(doubleValue);
System.out.println(
"double转BigDecimal: " +doubleToBigDecimal);//3.String转BigDecimal String stringValue = "10.123";
BigDecimal stringToBigDecimal
= newBigDecimal(stringValue);
System.out.println(
"String转BigDecimal: " +stringToBigDecimal);//long转BigDecimal long longValue = 10L;
BigDecimal longToBigDecimal
= newBigDecimal(longValue);
System.out.println(
"long转BigDecimal: " + longToBigDecimal);

3.bigDecimal精度处理

        //小数点的处理
        BigDecimal decimal = new BigDecimal("10.123456789");
System.out.println(
"原始数据: " +decimal);//截取小数点后两位 BigDecimal decimal2 = decimal.setScale(2, RoundingMode.HALF_UP);
System.out.println(
"截取小数点后两位: " +decimal2);//截取小数点后四位 BigDecimal decimal4 = decimal.setScale(4, RoundingMode.HALF_UP);
System.out.println(
"截取小数点后四位: " +decimal4);//截取小数点后六位 (超过6位的小数点将被舍弃) BigDecimal decimal6 = decimal.setScale(6, RoundingMode.HALF_UP);
System.out.println(
"截取小数点后六位: " +decimal6);//截取小数点后八位 (超过8位的小数点将被舍弃) BigDecimal decimal8 = decimal.setScale(8, RoundingMode.HALF_UP);
System.out.println(
"截取小数点后八位: " +decimal8);//直接删除多余的小数位 BigDecimal test = new BigDecimal("9.56666");//直接删除多余的小数位 System.out.println("删除多余的小数位: " + test.setScale(2, BigDecimal.ROUND_DOWN));//四舍五入 BigDecimal test2 = new BigDecimal("9.56666");//四舍五入 System.out.println("四舍五入: " + test2.setScale(2, BigDecimal.ROUND_HALF_UP));double i = 3.856;//舍掉小数取整 System.out.println("舍掉小数取整:Math.floor(3.856)=" + (int) Math.floor(i));//四舍五入取整 System.out.println("四舍五入取整:(3.856)=" + new BigDecimal(i).setScale(0, BigDecimal.ROUND_HALF_UP));//四舍五入保留两位小数 System.out.println("四舍五入取整:(3.856)=" + new BigDecimal(i).setScale(2, BigDecimal.ROUND_HALF_UP));//凑整,取上限 System.out.println("凑整:Math.ceil(3.856)=" + (int) Math.ceil(i));//舍掉小数取整 System.out.println("舍掉小数取整:Math.floor(-3.856)=" + (int) Math.floor(-i));//四舍五入取整 System.out.println("四舍五入取整:(-3.856)=" + new BigDecimal(-i).setScale(0, BigDecimal.ROUND_HALF_UP));//四舍五入保留两位小数 System.out.println("四舍五入取整:(-3.856)=" + new BigDecimal(-i).setScale(2, BigDecimal.ROUND_HALF_UP));//凑整,取上限 System.out.println("凑整(-3.856)=" + (int) Math.ceil(-i));

4.bigDecimal的比较

        BigDecimal a1 = new BigDecimal("10.123");
BigDecimal b1
= new BigDecimal("2.123");//等于 System.out.println(a1.compareTo(b1) == 0);//大于 System.out.println(a1.compareTo(b1) > 0);//小于 System.out.println(a1.compareTo(b1) < 0);//大于等于 System.out.println(a1.compareTo(b1) >= 0);//小于等于 System.out.println(a1.compareTo(b1) <= 0);

5.bigDecimal的其他常用方法和函数

        BigDecimal a2 = new BigDecimal("10.123");
BigDecimal b2
= new BigDecimal("2.123");//绝对值 System.out.println("绝对值: " +a2.abs());//向上取整 System.out.println("向上取整: " +Math.ceil(a2.doubleValue()));//向下取整 System.out.println("向下取整: " +Math.floor(a2.doubleValue()));//四舍五入 System.out.println("四舍五入: " + a2.setScale(2, BigDecimal.ROUND_HALF_UP));//取整 System.out.println("取整: " + a2.setScale(0, BigDecimal.ROUND_HALF_UP));//取余 System.out.println("取余: " +a2.remainder(b2));//最大值 System.out.println("最大值: " +a2.max(b2));//最小值 System.out.println("最小值: " +a2.min(b2));//平方根 System.out.println("平方根: " +Math.sqrt(a2.doubleValue()));//正弦 System.out.println("正弦: " +Math.sin(a2.doubleValue()));//余弦 System.out.println("余弦: " +Math.cos(a2.doubleValue()));//正切 System.out.println("正切: " +Math.tan(a2.doubleValue()));//自然对数 System.out.println("自然对数: " +Math.log(a2.doubleValue()));//常用函数 System.out.println("常用函数: " +Math.exp(a2.doubleValue()));//百分数 System.out.println("百分数: " + a2.movePointLeft(2));//科学计数法 System.out.println("科学计数法: " +a2.toEngineeringString());//格式化输出 System.out.printf("格式化输出: " + "%.2f", a2.doubleValue());

6.bigdecimal精度丢失问题

        //BigDecimal类用于精确的十进制数值计算。当使用BigDecimal进行运算时,//如果不正确处理精度模式(如ROUND_HALF_UP),可能会导致精度失去控制,即数值并非预期的结果。//解决方法://1.在进行加减乘除等运算时,确保正确设置BigDecimal的精度和舍入模式。//2.如果需要对BigDecimal对象进行多次运算,可以使用BigDecimal的setScale方法设置统一的精度和舍入模式。//3.在创建BigDecimal对象时,如果使用基本类型double作为参数,应该使用BigDecimal的valueOf方法,而不是new BigDecimal(double),因为后者可能会导致精度问题。
        BigDecimal bigDecimal1 = new BigDecimal("1.234");
BigDecimal bigDecimal2
= new BigDecimal("5.678");//正确设置舍入模式和精度:结果保留3位小数,并进行四舍五入 BigDecimal result = bigDecimal1.add(bigDecimal2).setScale(3, RoundingMode.HALF_UP);
System.out.println(
"结果: " +result);//bigdecimal精度格式化问题://BigDecimal类提供了toPlainString方法,可以将BigDecimal对象转换为字符串,//但是该方法并不提供精度控制,如果需要精度控制,可以使用BigDecimal的toString方法。//另外,BigDecimal类提供了format方法,可以格式化BigDecimal对象,//该方法可以指定输出的格式,包括整数位数、小数位数、是否显示符号、是否使用科学计数法等。 DecimalFormat df = new DecimalFormat("#,###.#################");
DecimalFormat df01
= new DecimalFormat("#0.00");
BigDecimal bigDecimal3
= new BigDecimal("1234567890.1234567890");//格式化输出: 1234567890.1234567890 System.out.println("格式化输出: " +bigDecimal3.toPlainString());//格式化输出: 123,456,789,0.1234567890 System.out.println("格式化输出: " +df.format(bigDecimal3.doubleValue()));
System.out.println(
"格式化输出: " + df01.format(bigDecimal3.doubleValue()));