У нас ограниченные возможности для хранения переменных в нашей рабочей памяти. Отслеживание изменений в голове потребляет в геометрической прогрессии больше энергии на каждом шагу.

// more variable
sum1 = v.x
sum2 := sum1 + v.y
sum3 := sum2 + v.z
sum4 := sum3 + v.w
// less variable
sum = sum + v.x
sum = sum + v.y
sum = sum + v.z
sum = sum + v.w

Каждая переменная может быть изменена. Если sum1, sum2, sum3, sum4 константы, это менее напряженно.

// longer execution path to track
public void SomeFunction(int age)
{
    if (age >= 0) {
        // Do Something
    } else {
        System.out.println("invalid age");
    }
}
// shorter execution path to track
public void SomeFunction(int age)
{
    if (age < 0){
        System.out.println("invalid age");
        return;
    }
    
    // Do Something
}

Ранний возврат сокращает пути выполнения, которые нам нужно отслеживать в голове. Чем короче путь к выводу, тем лучше.

Нелокальная логика

Мы предпочитаем непрерывную, линейную и изолированную логику. Позвольте мне объяснить, что я имею в виду

  • непрерывный: строка 2 должна быть связана со строкой 1, они соединены вместе, чтобы выразить тесную причинно-следственную связь
  • линейный: вы читаете код сверху вниз, код выполняется сверху вниз
  • изолированный: все, о чем вам нужно заботиться, находится в одном месте
// continuous, linear, isolated
private static boolean search(int[] x, int srchint) {
  for (int i = 0; i < x.length; i++)
     if (srchint == x[i])
        return true;
  return false;
}

Логическая локальность - самая частая проблема, и она субъективна. То, что вас волнует, определяет, что для вас значит «местный». Рефакторинг - это рефакторинг, он заключается в том, чтобы перетасовать логику, реорганизовать ее определенным образом, чтобы сделать ее удобочитаемой.

Есть три причины сделать логику нелокальной.

  • стиль кодирования: глобальная переменная, simd intrinsics v.s. Вычисления GPU в стиле spmd, обратный вызов v.s. сопрограмма
  • обобщение: для повторного использования кода нам нужно объединить несколько путей выполнения в один
  • нефункциональные требования: он совмещен во времени и пространстве (как в исходном коде, так и во время выполнения)

Нелокальная логика: стиль кодирования

Общение через глобальную переменную сделает причинно-следственную связь неявной, что затруднит объединение требуемых логических элементов.

// declare global variable
int g_mode;
 
void doSomething()
{
    g_mode = 2; // set the global g_mode variable to 2
}
 
int main()
{
    g_mode = 1; // note: this sets the global g_mode variable to 1.  It does not declare a local g_mode variable!
    doSomething();
    // Programmer still expects g_mode to be 1
    // But doSomething changed it to 2!
 
    if (g_mode == 1)
        std::cout << "No threat detected.\n";
    else
        std::cout << "Launching nuclear missiles...\n";
 
    return 0;
}

Тривиально перевести ту же логику из использования глобальной переменной в явную передачу контекста через аргументы. Это стиль кодирования, который мы выбираем, чтобы сделать код нечитаемым.

Второй пример касается программирования SIMD. Напишите код для управления исполнителем SIMD, нам нужно позаботиться о нескольких «полосах данных» одновременно. Обратите внимание, что %ymm0 - это 256-битный регистр, 8 полос данных для 32-битного:

LBB0_3:
	vpaddd    %ymm5, %ymm1, %ymm8
	vblendvps %ymm7, %ymm8, %ymm1, %ymm1
	vmulps    %ymm0, %ymm3, %ymm7
	vblendvps %ymm6, %ymm7, %ymm3, %ymm3
	vpcmpeqd  %ymm4, %ymm1, %ymm8
	vmovaps   %ymm6, %ymm7
	vpandn    %ymm6, %ymm8, %ymm6
	vpand     %ymm2, %ymm6, %ymm8
	vmovmskps %ymm8, %eax
	testl     %eax, %eax
	jne       LBB0_3

Вместо того, чтобы указывать, как применять одну и ту же операцию к нескольким дорожкам данных, гораздо проще написать код для работы с одной «полосой данных»:

float powi(float a, int b) {
    float r = 1;
    while (b--)
        r *= a;
    return r;
}

Для компиляции кода из стиля SPMD в стиль SIMD требуется https://ispc.github.io/ispc.html, они эквивалентны.

Третий пример - обратный вызов против. совместный распорядок

const verifyUser = function(username, password, callback){
   dataBase.verifyUser(username, password, (error, userInfo) => {
       if (error) {
           callback(error)
       }else{
           dataBase.getRoles(username, (error, roles) => {
               if (error){
                   callback(error)
               }else {
                   dataBase.logAccess(username, (error) => {
                       if (error){
                           callback(error);
                       }else{
                           callback(null, userInfo, roles);
                       }
                   })
               }
           })
       }
   })
};

по сравнению с

const verifyUser = async function(username, password){
   try {
       const userInfo = await dataBase.verifyUser(username, password);
       const rolesInfo = await dataBase.getRoles(userInfo);
       const logStatus = await dataBase.logAccess(userInfo);
       return userInfo;
   }catch (e){
       //handle errors as needed
   }
};

Совместная процедура делает логику линейной, идущей сверху вниз. Код написан в стиле обратного вызова слева направо. Но они равноценны. Мы можем выбрать стиль кодирования, чтобы сделать код более читаемым, если язык программирования позволяет нам это сделать.

Нелокальная логика: обобщение

Чтобы обобщить, вы должны специализироваться. Если вам нужно поддерживать специализированный код для 10 продуктов, использующих большую часть общего кода. Как часто вам нужно вместе продумывать логику 10 продуктов? Очень часто вы думаете о том, как работает 1 конкретный тип продукта. Но, читая обобщенный код, вы вынуждены пропускать код для других 9 типов. Пропуск вызывает настоящую когнитивную нагрузку.

Вот простой пример

public double PrintBill()
{
    double sum = 0;
    foreach (Drink i in drinks)
    {
        sum += i.price;
    }
    drinks.Clear();
    return sum;
}

Мы думаем, что PrintBill слишком полезен, мы должны использовать его повторно. Но для счастливого часа нам нужно получить скидку на напиток. Код стал

interface BillingStrategy
{
    double GetActPrice(double rawPrice);
}
// Normal billing strategy (unchanged price)
class NormalStrategy : BillingStrategy
{
    public double GetActPrice(Drink drink)
    {
        return drink.price;
    }
}
// Strategy for Happy hour (50% discount)
class HappyHourStrategy : BillingStrategy
{
    public double GetActPrice(Drink drink)
    {
        return drink.price * 0.5;
    }
}
public double PrintBill(BillingStrategy billingStrategy)
{
    double sum = 0;
    foreach (Drink i in drinks)
    {
        sum += billingStrategy.GetActPrice(i);
    }
    drinks.Clear();
    return sum;
}

Чтобы обобщить PrintBill как на счастливый час, так и на обычный час, мы должны специализироваться на стратегии выставления счетов. Для чтения обобщенного кода он определенно менее читабелен, чем исходная версия.

Кроме того, для поддержки всех типов случаев код не может быть конкретным для одного сценария. Это создаст много «точек вариации». Вполне возможно, что для определенного сценария вариация - это просто пустой имп, чтобы заполнить дыру. Например, если нам нужна дополнительная плата за обслуживание в обычное время. Код выглядит как

interface BillingStrategy
{
    double GetActPrice(double rawPrice);
}
// Normal billing strategy (unchanged price)
class NormalStrategy : BillingStrategy
{
    public double GetActPrice(Drink drink)
    {
        return drink.price;
    }
    public double GetExtraCharge()
    {
        return 1;
    }
}
// Strategy for Happy hour (50% discount)
class HappyHourStrategy : BillingStrategy
{
    public double GetActPrice(Drink drink)
    {
        return drink.price * 0.5;
    }
    public double GetExtraCharge()
    {
        return 0;
    }
}
public double PrintBill(BillingStrategy billingStrategy)
{
    double sum = 0;
    foreach (Drink i in drinks)
    {
        sum += billingStrategy.GetActPrice(i);
    }
    sum += billingStrategy.GetExtraCharge();
    drinks.Clear();
    return sum;
}

Если вы поддерживаете логику счастливого часа, строка sum += billingStrategy.GetExtraCharge(); вам совершенно не нужна. Но вы все равно вынуждены это читать.

Также есть много способов написать «разветвленный» код. перегрузка функции, шаблон класса, полиморфизм объекта, объект функции, таблица переходов и простой if / else. Зачем нам нужно так много способов выразить простое «если»? Это абсурдно.

Нелокальная логика: нефункциональные требования

Функциональный и нефункциональный код переплетены, что затрудняет интерпретацию кода человеком. Основная цель исходного кода должна заключаться в описании причинно-следственной цепочки всего. Когда происходит какое-то x, но y не следует, мы воспринимаем это как ошибку, что я имею в виду как цепочку причинно-следственных связей. Чтобы эта причинно-следственная цепочка выполнялась на физическом оборудовании, необходимо указать множество деталей. Например:

  • значение размещается в стеке или куче
  • аргумент передается как копия или указатель
  • как регистрировать выполнение, чтобы мы могли отслеживать при отладке
  • как выполнять параллельные «бизнес-процессы» в одном потоке, в нескольких потоках, на нескольких машинах в течение нескольких дней

Вот пример обработки ошибок

err := json.Unmarshal(input, &gameScores)
if ShouldLog(LevelTrace) {
  fmt.Println("json.Unmarshal", string(input))
}
metrics.Send("unmarshal.stats", err)
if err != nil {
   fmt.Println("json.Unmarshal failed", err, string(input))
   return fmt.Errorf("failed to read game scores: %v", err.Error())
}

Функциональный код - это просто json.Unmarshal(input, &gameScores) и if err != nil return. Мы добавили много нефункционального кода для обработки ошибки. Пропустить этот код нетривиально.

То, что вы видите, это не то, что вы получаете

Мы вынуждены представить, как код работает во время выполнения, когда он сильно отличается от исходного кода. Например:

public class DataRace extends Thread {
  private static volatile int count = 0; // shared memory
  public void run() {
    int x = count;
    count = x + 1;
  }
  public static void main(String args[]) {
    Thread t1 = new DataRace();
    Thread t2 = new DataRace();
    t1.start();
    t2.start();
  }
}

Счетчик не всегда x+1, учитывая, что есть другие потоки, выполняющие то же самое параллельно, они могут переопределить ваш x+1 своими x+1.

Мета-программирование также требует большой силы воображения. В отличие от вызова функции, вы не можете перейти к функции, которую вы вызвали, чтобы проверить, на чем вы строите. Например

int main() {
    for(int i = 0; i < 10; i++) {
        char *echo = (char*)malloc(6 * sizeof(char));
        sprintf(echo, "echo %d", i);
        system(echo);
    }
    return 0;
}

Код генерируется на лету. Нет простого способа проверить сгенерированный исходный код, чтобы убедиться в его правильности.

Незнакомая концепция

Мы используем имя и ссылку, чтобы связать одно понятие с другим. Когда связь не будет сильной, у нас будут проблемы и мы скажем, что она «не имеет смысла».

Мы думаем, что игра слева намного проще, чем игра справа. Почему? Потому что ladder и fire - знакомые концепции, сопоставленные с вашей реальной жизнью. Мы создаем новый опыт поверх существующего. Если код отделен от требований и от реальной концепции, его будет трудно понять.

func getThem(theList [][]int) [][]int {
	var list1 [][]int
	for _, x := range theList {
		if x[0] == 4 {
			list1 = append(list1, x)
		}
	}
	return list1
}

менее читабелен, чем

func getFlaggedCells(gameBoard []Cell) []Cell {
	var flaggedCells []Cell
	for _, cell := range gameBoard {
		if cell.isFlagged() {
			flaggedCells = append(flaggedCells, cell)
		}
	}
	return flaggedCells
}
type Cell []int
func (cell Cell) isFlagged() bool {
	return cell[0] == 4
}

Потому что [][]int незнакомо, но []Cell сопоставлено с жизненным опытом.

Связывание концепции кода с жизненным опытом осуществляется через «имя», которое представляет собой просто строку. Концепция связи между модулями кода может опираться на «ссылку». Вы определяете функцию и ссылаетесь на нее. Вы определяете тип и ссылаетесь на него. В среде IDE вы можете щелкнуть ссылку, чтобы перейти к ее определению. Когда связь сильная и четкая, мы можем читать код намного быстрее.

Понятия возникают из разложения. Каждый раз, когда мы разлагаемся, мы даем ему имя. Есть три вида декомпозиции:

  • Пространственная декомпозиция
  • Временная декомпозиция
  • Слой декомпозиции

Когда мы чувствуем, что проблема велика, мы всегда можем разбить ее этими тремя методами, просто не забудьте правильно назвать концепцию разбитой.